From 7c51d89b10ffe6e6f9aa7e1c08f6007dde21ce55 Mon Sep 17 00:00:00 2001 From: Rob Syme Date: Mon, 23 Mar 2026 17:05:03 -0400 Subject: [PATCH 01/16] Add ADR for hints process directive Propose a generic 'hints' process directive with namespaced keys as the extension point for executor-specific scheduling hints. First use case: AWS Batch consumable resources for license-seat management. Evaluates three options: dedicated consumableResources directive, overloading resourceLabels, and a new hints directive. Recommends hints for extensibility and separation of concerns. Ref: nextflow-io/nextflow#5917 Signed-off-by: Rob Syme --- adr/20260323-hints-process-directive.md | 136 ++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 adr/20260323-hints-process-directive.md diff --git a/adr/20260323-hints-process-directive.md b/adr/20260323-hints-process-directive.md new file mode 100644 index 0000000000..19a7542109 --- /dev/null +++ b/adr/20260323-hints-process-directive.md @@ -0,0 +1,136 @@ +# `hints` process directive for executor-specific scheduling hints + +- Authors: Rob Syme +- Status: draft +- Deciders: Paolo Di Tommaso, Ben Sherman, Rob Syme +- Date: 2026-03-23 +- Tags: directive, executor, scheduling + +Technical Story: [nextflow-io/nextflow#5917](https://github.com/nextflow-io/nextflow/issues/5917), [PR #6957](https://github.com/nextflow-io/nextflow/pull/6957) + +## Summary + +Introduce a `hints` process directive for executor-specific scheduling hints that don't map to existing resource directives. The first use case is AWS Batch consumable resources for license-seat-aware scheduling. + +## Problem Statement + +Users running commercially licensed software (e.g. DRAGEN, Schrodinger) need to limit concurrent job execution based on available license seats — not just within a single pipeline run (`maxForks`), but across multiple concurrent runs. AWS Batch introduced [resource-aware scheduling](https://docs.aws.amazon.com/batch/latest/userguide/resource-aware-scheduling.html) in February 2025, which models this natively via consumable resources. Nextflow has no way to pass this information to the executor. + +More broadly, cloud batch systems expose scheduling knobs that don't fit neatly into Nextflow's existing resource directives (`cpus`, `memory`, `disk`, `accelerator`, `queue`). Today users must resort to `clusterOptions` (string-based, fragile) or custom `ext` attributes (no standard semantics). A structured, namespaced directive would provide a clean extension point. + +## Goals or Decision Drivers + +- Avoid a proliferation of narrow, executor-specific directives (e.g. `consumableResources`, `schedulingPolicy`, etc.) +- Provide a single extension point that executors can consume selectively +- Use a structured format (not freeform strings like `clusterOptions`) +- Keep semantics clear: hints influence scheduling/placement but do not define core resource requirements +- Distinguish from `resourceLabels` which are metadata tags, not scheduling behavior + +## Non-goals + +- Creating/managing cloud-side resources (e.g. AWS consumable resources) from within Nextflow +- Client-side validation of hint values against cloud APIs +- Replacing existing directives (`cpus`, `memory`, `accelerator`, `queue`) — those remain the right place for standard resources + +## Considered Options + +### Option 1: Dedicated `consumableResources` directive + +```nextflow +process runDragen { + consumableResources 'my-dragen-license': 1 +} +``` + +- Good, because it's explicit and self-documenting +- Good, because the syntax maps cleanly to the AWS API +- Bad, because it adds a directive for a single executor feature +- Bad, because future executor-specific features would each need their own directive + +### Option 2: Overload `resourceLabels` + +```nextflow +process runDragen { + resourceLabels 'consumable-resource:my-dragen-license': 1 +} +``` + +- Good, because it reuses an existing directive +- Bad, because `resourceLabels` are metadata tags (write-only) — they should not influence scheduling behavior +- Bad, because it conflates two different concerns (labeling vs. scheduling) +- Bad, because `resourceLabels` uses replacement semantics in config overrides — a systems administrator setting `resourceLabels` for cost tracking in a shared compute environment would have their labels wiped out if a user sets consumable resources (and vice versa), since the entire map is replaced rather than merged + +### Option 3: New `hints` directive with namespaced keys + +```nextflow +process runDragen { + hints 'consumable-resource:my-dragen-license': 1 +} +``` + +- Good, because it provides a single, extensible directive for all executor-specific hints +- Good, because namespaced keys make intent clear and avoid collisions +- Good, because it clearly signals "this influences scheduling" without overloading existing directives +- Good, because executors can selectively consume the hints they understand and ignore the rest +- Bad, because it introduces a new directive (though one that replaces many potential future ones) + +## Solution or decision outcome + +Introduce a `hints` process directive (Option 3) with namespaced keys. Executors consume the hints they understand and silently ignore the rest. + +## Rationale & discussion + +### Syntax + +The directive accepts a map of namespaced key-value pairs. It is repeatable (multiple calls accumulate) and supports config overrides: + +```nextflow +// In process DSL +process runDragen { + hints 'consumable-resource:my-dragen-license': 1 + hints 'consumable-resource:other-license': 2 + cpus 4 + memory '16 GB' + + script: + """ + dragen --ref-dir /ref ... + """ +} + +// In nextflow.config +process { + withName: 'runDragen' { + hints = ['consumable-resource:my-dragen-license': 1] + } +} +``` + +### Namespacing + +Keys use a colon-separated namespace to group related hints: + +| Namespace | Key example | Value | Executor | +|-----------|-------------|-------|----------| +| `consumable-resource` | `consumable-resource:my-license` | Integer (quantity) | AWS Batch | + +Future namespaces could include scheduling priorities, placement constraints, or other executor-specific features without requiring new directives. + +### Executor mapping + +For the initial implementation, the AWS Batch executor maps `consumable-resource:*` hints to `ConsumableResourceProperties` on `RegisterJobDefinitionRequest`: + +- The portion after `consumable-resource:` becomes the resource name/ARN +- The value becomes the quantity +- Resources are set on the job definition (not as submit-time overrides) +- Different hint configurations produce distinct job definition hashes + +### Important caveat: FIFO queue ordering + +AWS Batch job queues use FIFO ordering by default. A job waiting for a consumable resource blocks all subsequent jobs in the same queue — even those that don't require the resource. Users should use a dedicated job queue (via the `queue` directive) or a fair-share scheduling policy. + +## Links + +- [AWS Batch Resource-Aware Scheduling](https://docs.aws.amazon.com/batch/latest/userguide/resource-aware-scheduling.html) +- [GitHub Issue #5917](https://github.com/nextflow-io/nextflow/issues/5917) +- [PR #6957](https://github.com/nextflow-io/nextflow/pull/6957) — initial implementation (to be reworked) From 87181e3fc5e3e40afb7474f105508954daf0c6b1 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Tue, 14 Apr 2026 23:48:42 +0200 Subject: [PATCH 02/16] Fix module info formatting separator [ci fast] Signed-off-by: Paolo Di Tommaso Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Paolo Di Tommaso --- .../src/main/groovy/nextflow/cli/module/CmdModuleInfo.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleInfo.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleInfo.groovy index b0dd5bba9c..5a539ff31b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleInfo.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleInfo.groovy @@ -161,7 +161,7 @@ class CmdModuleInfo extends CmdBase { // Generate and display usage template println "" println "Usage Template:" - println "-" * 80 + println "---------------" println generateUsageTemplate(reference, metadata).join(" \\\n ") println "" } From 07feadaf9cd95850c256f95f1aa18bf6051b82d0 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Tue, 14 Apr 2026 17:33:42 -0500 Subject: [PATCH 03/16] Update ADR Signed-off-by: Ben Sherman --- adr/20260323-hints-process-directive.md | 192 +++++++++++++++--------- 1 file changed, 118 insertions(+), 74 deletions(-) diff --git a/adr/20260323-hints-process-directive.md b/adr/20260323-hints-process-directive.md index 19a7542109..be3ebd76db 100644 --- a/adr/20260323-hints-process-directive.md +++ b/adr/20260323-hints-process-directive.md @@ -1,136 +1,180 @@ # `hints` process directive for executor-specific scheduling hints - Authors: Rob Syme -- Status: draft +- Status: accepted - Deciders: Paolo Di Tommaso, Ben Sherman, Rob Syme - Date: 2026-03-23 - Tags: directive, executor, scheduling -Technical Story: [nextflow-io/nextflow#5917](https://github.com/nextflow-io/nextflow/issues/5917), [PR #6957](https://github.com/nextflow-io/nextflow/pull/6957) - ## Summary -Introduce a `hints` process directive for executor-specific scheduling hints that don't map to existing resource directives. The first use case is AWS Batch consumable resources for license-seat-aware scheduling. +Introduce a `hints` process directive for executor-specific scheduling hints that don't map to existing directives. ## Problem Statement -Users running commercially licensed software (e.g. DRAGEN, Schrodinger) need to limit concurrent job execution based on available license seats — not just within a single pipeline run (`maxForks`), but across multiple concurrent runs. AWS Batch introduced [resource-aware scheduling](https://docs.aws.amazon.com/batch/latest/userguide/resource-aware-scheduling.html) in February 2025, which models this natively via consumable resources. Nextflow has no way to pass this information to the executor. +Many executors can be configured in various ways on a per-task basis. For example: -More broadly, cloud batch systems expose scheduling knobs that don't fit neatly into Nextflow's existing resource directives (`cpus`, `memory`, `disk`, `accelerator`, `queue`). Today users must resort to `clusterOptions` (string-based, fragile) or custom `ext` attributes (no standard semantics). A structured, namespaced directive would provide a clean extension point. +- AWS Batch jobs can use *consumable resources* to limit concurrent job execution based on non-standard resources such as software license seats. -## Goals or Decision Drivers +- Google Batch jobs can specify a *provisioning model* to control the use of spot vs on-demand VMs on a per-task basis. -- Avoid a proliferation of narrow, executor-specific directives (e.g. `consumableResources`, `schedulingPolicy`, etc.) -- Provide a single extension point that executors can consume selectively -- Use a structured format (not freeform strings like `clusterOptions`) -- Keep semantics clear: hints influence scheduling/placement but do not define core resource requirements -- Distinguish from `resourceLabels` which are metadata tags, not scheduling behavior +- Seqera Scheduler supports a variety of resource and scheduling settings, including spot/on-demand provisioning. -## Non-goals +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. -- Creating/managing cloud-side resources (e.g. AWS consumable resources) from within Nextflow -- Client-side validation of hint values against cloud APIs -- Replacing existing directives (`cpus`, `memory`, `accelerator`, `queue`) — those remain the right place for standard resources +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. -## Considered Options +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. -### Option 1: Dedicated `consumableResources` directive +There exist a few generic process directives already: -```nextflow -process runDragen { - consumableResources 'my-dragen-license': 1 -} -``` +- 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 -- Good, because it's explicit and self-documenting -- Good, because the syntax maps cleanly to the AWS API -- Bad, because it adds a directive for a single executor feature -- Bad, because future executor-specific features would each need their own directive +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. -### Option 2: Overload `resourceLabels` +## Goals -```nextflow -process runDragen { - resourceLabels 'consumable-resource:my-dragen-license': 1 -} -``` +- Provide a way to apply executor-specific settings to individual processes or tasks -- Good, because it reuses an existing directive -- Bad, because `resourceLabels` are metadata tags (write-only) — they should not influence scheduling behavior -- Bad, because it conflates two different concerns (labeling vs. scheduling) -- Bad, because `resourceLabels` uses replacement semantics in config overrides — a systems administrator setting `resourceLabels` for cost tracking in a shared compute environment would have their labels wiped out if a user sets consumable resources (and vice versa), since the entire map is replaced rather than merged +- Avoid the proliferation of narrow, executor-specific directives (e.g. `consumableResources`, `schedulingPolicy`, etc.) -### Option 3: New `hints` directive with namespaced keys +- Provide a single extension point that executors can consume selectively -```nextflow -process runDragen { - hints 'consumable-resource:my-dragen-license': 1 -} -``` +- Allow settings to be specified as key-values, providing validation where possible + +## Non-goals -- Good, because it provides a single, extensible directive for all executor-specific hints -- Good, because namespaced keys make intent clear and avoid collisions -- Good, because it clearly signals "this influences scheduling" without overloading existing directives -- Good, because executors can selectively consume the hints they understand and ignore the rest -- Bad, because it introduces a new directive (though one that replaces many potential future ones) +- Replacing existing directives (`cpus`, `memory`, `accelerator`, `queue`) — those remain the right place for standard resources -## Solution or decision outcome +## Decision -Introduce a `hints` process directive (Option 3) with namespaced keys. Executors consume the hints they understand and silently ignore the rest. +Introduce a `hints` process directive with namespaced keys. Executors consume the hints they understand and silently ignore the rest. -## Rationale & discussion +## Core Capabilities ### Syntax -The directive accepts a map of namespaced key-value pairs. It is repeatable (multiple calls accumulate) and supports config overrides: +The `hints` directive accepts a map of key-value pairs: -```nextflow -// In process DSL +```groovy +// process definition process runDragen { - hints 'consumable-resource:my-dragen-license': 1 - hints 'consumable-resource:other-license': 2 cpus 4 memory '16 GB' + hints consumableResources: 'my-dragen-license=1,other-license=2' script: """ dragen --ref-dir /ref ... """ } +``` -// In nextflow.config +```groovy +// process config process { withName: 'runDragen' { - hints = ['consumable-resource:my-dragen-license': 1] + hints = [ + consumableResources: 'my-dragen-license=1,other-license=2' + ] } } ``` +Both keys and values are arbitrary strings. Executors are responsible for defining which hints they recognize, as well as the expected structure for a given hint value. This approach keeps the `hints` directive simple (`Map`) while allowing executors to structure hint values however they want (as long as it's a string). + +In the above example, the `consumableResources` hint is given as a comma-separated string of `=` entries. The AWS Batch executor would parse this string into a map and supply it to each job request using `ConsumableResourceProperties`. + ### Namespacing -Keys use a colon-separated namespace to group related hints: +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' +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. -| Namespace | Key example | Value | Executor | -|-----------|-------------|-------|----------| -| `consumable-resource` | `consumable-resource:my-license` | Integer (quantity) | AWS Batch | +- **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. -Future namespaces could include scheduling priorities, placement constraints, or other executor-specific features without requiring new directives. +### 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'] -### Executor mapping + // 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'] + } +} +``` -For the initial implementation, the AWS Batch executor maps `consumable-resource:*` hints to `ConsumableResourceProperties` on `RegisterJobDefinitionRequest`: +While this approach may lead to duplication, it gives users and developers more control over which hints are applied in a given context. -- The portion after `consumable-resource:` becomes the resource name/ARN -- The value becomes the quantity -- Resources are set on the job definition (not as submit-time overrides) -- Different hint configurations produce distinct job definition hashes +### Initial hint catalog -### Important caveat: FIFO queue ordering +The following hints should be supported initially: -AWS Batch job queues use FIFO ordering by default. A job waiting for a consumable resource blocks all subsequent jobs in the same queue — even those that don't require the resource. Users should use a dedicated job queue (via the `queue` directive) or a fair-share scheduling policy. +| Hint name | Executors | Use case | +|--|--|--| +| `consumableResources` | AWS Batch | License-aware scheduling ([#5917](https://github.com/nextflow-io/nextflow/issues/5917)) | +| `scheduling.priority` | AWS Batch | Job scheduling priority ([#6998](https://github.com/nextflow-io/nextflow/issues/6998)) | +| `scheduling.provisioningModel` | Google Batch | Spot VM scheduling ([#3530](https://github.com/nextflow-io/nextflow/issues/3530)) | ## Links -- [AWS Batch Resource-Aware Scheduling](https://docs.aws.amazon.com/batch/latest/userguide/resource-aware-scheduling.html) -- [GitHub Issue #5917](https://github.com/nextflow-io/nextflow/issues/5917) -- [PR #6957](https://github.com/nextflow-io/nextflow/pull/6957) — initial implementation (to be reworked) +- [Community issue](https://github.com/nextflow-io/nextflow/issues/5917) From a6093f7f36aaa1f7801f1f9370a42b2a2b4a2bd3 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Wed, 15 Apr 2026 00:40:49 +0200 Subject: [PATCH 04/16] Add `hints` process directive for executor-specific scheduling hints Introduce a new map-type `hints` process directive that provides a structured, extensible way for pipeline authors to pass executor-specific scheduling hints. Keys use `[executor/][scope.]hintName` format. Core features: - Multiple `hints` calls within a process body accumulate (merge) - Config overrides via withName:/withLabel: replace the entire map - Values support String, Integer, and Closure types - Two-tier validation: warnings for unknown unprefixed keys (global registry), errors for unknown executor-prefixed keys - Initial global catalog contains only `consumableResource` Seqera Platform integration: - `seqera/machineRequirement.*` hints map to MachineRequirementOpts fields (arch, provisioning, maxSpotAttempts, machineTypes, diskType, diskThroughputMiBps, diskIops, diskEncrypted, diskAllocation, diskMountPath, diskSize, capacityMode) - Hints override Seqera config scope values at the task level - Unknown `seqera/` keys produce an error Ref: nextflow-io/nextflow#5917, nextflow-io/nextflow#6960 Signed-off-by: Paolo Di Tommaso Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Paolo Di Tommaso --- .../groovy/nextflow/processor/HintDefs.groovy | 83 +++++++ .../nextflow/processor/TaskConfig.groovy | 4 + .../nextflow/script/ProcessConfig.groovy | 4 + .../nextflow/script/dsl/ProcessBuilder.groovy | 20 ++ .../nextflow/processor/HintDefsTest.groovy | 93 +++++++ .../nextflow/processor/TaskConfigTest.groovy | 43 ++++ .../script/dsl/ProcessBuilderTest.groovy | 35 +++ .../nextflow/script/types/TaskConfig.java | 8 + .../seqera/executor/SeqeraTaskHandler.groovy | 8 +- .../src/main/io/seqera/util/HintHelper.groovy | 131 ++++++++++ .../test/io/seqera/util/HintHelperTest.groovy | 228 ++++++++++++++++++ 11 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy create mode 100644 modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy create mode 100644 plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy create mode 100644 plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy 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..f10b242f35 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy @@ -0,0 +1,83 @@ +/* + * 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 +import groovy.util.logging.Slf4j + +/** + * Defines the global hint key registry and provides validation + * for the {@code hints} process directive. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class HintDefs { + + /** + * Global registry of known unprefixed hint keys and their expected value types. + * Executor-prefixed keys (e.g. {@code seqera/machineRequirement.arch}) are + * validated by the target executor, not this registry. + */ + static final Map KNOWN_HINTS = [ + 'consumableResource': String + ] + + /** + * Validates the given hints map against the global registry. + *

+ * Unprefixed keys are checked against {@link #KNOWN_HINTS}: + *

    + *
  • Unknown keys produce a warning with a "did you mean?" suggestion if a close match exists
  • + *
  • Value types are validated (must resolve to String or Integer)
  • + *
+ * Executor-prefixed keys (containing {@code /}) are skipped — they are validated by the target executor. + * + * @param hints the resolved hints map + */ + static void validateHints(Map hints) { + if( !hints ) + return + + for( Map.Entry entry : hints.entrySet() ) { + final key = entry.key + final value = entry.value + + // skip executor-prefixed keys — validated by the executor + if( key.contains('/') ) + continue + + // validate value type + if( value != null && !(value instanceof String) && !(value instanceof Integer) ) { + throw new IllegalArgumentException("Invalid hint value type for key '${key}': expected String or Integer, got ${value.getClass().getName()}") + } + + // validate key against registry + if( !KNOWN_HINTS.containsKey(key) ) { + final suggestions = KNOWN_HINTS.keySet().toList().closest(key) + if( suggestions ) { + log.warn "Unknown process hint: '${key}' — did you mean '${suggestions.first()}'?" + } + else { + log.warn "Unknown process hint: '${key}'" + } + } + } + } + +} 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..c2b6a6c66f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy @@ -59,6 +59,7 @@ class ProcessBuilder { 'executor', 'ext', 'fair', + 'hints', 'label', 'machineType', 'maxErrors', @@ -377,6 +378,25 @@ class ProcessBuilder { config.put('resourceLabels', allLabels) } + /** + * 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 + + def allHints = (Map)config.get('hints') + if( !allHints ) { + allHints = [:] + } + allHints += map + config.put('hints', allHints) + } + private static final List VALID_RESOURCE_LIMITS = List.of('cpus', 'memory', 'disk', 'time') /** 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..5c4f38524d --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy @@ -0,0 +1,93 @@ +/* + * 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 known hint key'() { + when: + HintDefs.validateHints([consumableResource: 'my-license']) + then: + noExceptionThrown() + } + + def 'should warn on unknown key with close match'() { + // consumableResourc is close to consumableResource + when: + HintDefs.validateHints([consumableResourc: 'my-license']) + then: + noExceptionThrown() + // warning is logged — verified by log output in integration tests + } + + def 'should warn on unknown key with no close match'() { + when: + HintDefs.validateHints([somethingRandom: 'value']) + then: + noExceptionThrown() + } + + def 'should reject invalid value type'() { + when: + HintDefs.validateHints([consumableResource: ['a', 'b']]) + then: + def e = thrown(IllegalArgumentException) + e.message.contains('Invalid hint value type') + e.message.contains('consumableResource') + } + + def 'should accept string and integer values'() { + when: + HintDefs.validateHints([consumableResource: 'my-license', 'scheduling.priority': 10]) + then: + noExceptionThrown() + } + + def 'should skip executor-prefixed keys'() { + when: + HintDefs.validateHints(['seqera/machineRequirement.arch': 'arm64', 'seqera/unknownKey': 'value']) + then: + noExceptionThrown() + } + + def 'should handle null and empty maps'() { + when: + HintDefs.validateHints(null) + then: + noExceptionThrown() + + when: + HintDefs.validateHints([:]) + then: + noExceptionThrown() + } + + def 'should accept null hint value'() { + when: + HintDefs.validateHints([consumableResource: null]) + then: + noExceptionThrown() + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy index 5703eecab4..854c8937e9 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', consumableResource: 'my-license' ) + + then: + process.get('hints') == ['seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license'] + + when: + def config = process.createTaskConfig() + then: + config.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license'] + } + + 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', consumableResource: 'my-license' ) + then: + process.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license'] + + 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..eaa33f16aa 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,41 @@ 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 store closure values in hints' () { + given: + def builder = createBuilder() + def config = builder.getConfig() + + when: + def closure = { 'spot' } + builder.hints 'seqera/machineRequirement.provisioning': closure + then: + config.getHints()['seqera/machineRequirement.provisioning'].is(closure) + } + def 'should check a valid label' () { expect: 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..9e150ce60c 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 @@ -320,6 +320,14 @@ public interface TaskConfig { """) String getQueue(); + @Constant("hints") + @Description(""" + The `hints` directive allows you to specify executor-specific scheduling hints as key-value pairs. + + [Read more](https://nextflow.io/docs/latest/reference/process.html#hints) + """) + Map getHints(); + @Constant("resourceLabels") @Description(""" The `resourceLabels` directive allows you to specify custom name-value pairs which are applied to the compute resources used for the process execution. 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 83da4cbc5d..fff33dfd22 100644 --- a/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraTaskHandler.groovy +++ b/plugins/nf-seqera/src/main/io/seqera/executor/SeqeraTaskHandler.groovy @@ -30,6 +30,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 @@ -113,8 +114,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..7eb8298a7f --- /dev/null +++ b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy @@ -0,0 +1,131 @@ +/* + * 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 + +/** + * 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.' + + /** + * Known {@code seqera/machineRequirement.*} hint keys (after stripping the {@code seqera/} prefix). + */ + static final Set KNOWN_KEYS = Set.of( + 'machineRequirement.arch', + 'machineRequirement.provisioning', + 'machineRequirement.maxSpotAttempts', + 'machineRequirement.machineTypes', + 'machineRequirement.diskType', + 'machineRequirement.diskThroughputMiBps', + 'machineRequirement.diskIops', + 'machineRequirement.diskEncrypted', + 'machineRequirement.diskAllocation', + 'machineRequirement.diskMountPath', + 'machineRequirement.diskSize', + 'machineRequirement.capacityMode' + ) + + /** + * Extract {@code seqera/}-prefixed hints from the hints map and validate them. + * Raises an error for unrecognized {@code seqera/} keys. + * + * @param hints the full hints map from task config + * @return a map of hint keys (with {@code seqera/} prefix stripped) to values + */ + static Map extractSeqeraHints(Map hints) { + if( !hints ) + return Collections.emptyMap() + + final result = new LinkedHashMap() + for( Map.Entry entry : hints.entrySet() ) { + final key = entry.key + if( !key.startsWith(PREFIX) ) + continue + + final stripped = key.substring(PREFIX.length()) + if( !KNOWN_KEYS.contains(stripped) ) { + throw new IllegalArgumentException("Unknown Seqera Platform hint: '${key}' — supported keys are: ${KNOWN_KEYS.collect { PREFIX + it }.sort().join(', ')}") + } + result.put(stripped, entry.value) + } + return result + } + + /** + * Overlay {@code seqera/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 + + // build a map from current opts, then overlay hints + final Map merged = new LinkedHashMap<>() + + // copy existing config-scope values + if( baseOpts.arch ) merged.arch = baseOpts.arch + if( baseOpts.provisioning ) merged.provisioning = baseOpts.provisioning + if( baseOpts.maxSpotAttempts != null ) merged.maxSpotAttempts = baseOpts.maxSpotAttempts + if( baseOpts.machineTypes ) merged.machineTypes = baseOpts.machineTypes + if( baseOpts.diskType ) merged.diskType = baseOpts.diskType + if( baseOpts.diskThroughputMiBps != null ) merged.diskThroughputMiBps = baseOpts.diskThroughputMiBps + if( baseOpts.diskIops != null ) merged.diskIops = baseOpts.diskIops + if( baseOpts.diskEncrypted != null ) merged.diskEncrypted = baseOpts.diskEncrypted + if( baseOpts.diskAllocation ) merged.diskAllocation = baseOpts.diskAllocation + if( baseOpts.diskMountPath ) merged.diskMountPath = baseOpts.diskMountPath + if( baseOpts.diskSize ) merged.diskSize = baseOpts.diskSize.toString() + if( baseOpts.capacityMode ) merged.capacityMode = baseOpts.capacityMode + + // overlay hints — strip the machineRequirement. prefix to get field names + for( Map.Entry entry : seqeraHints.entrySet() ) { + final fieldName = entry.key.substring(MR_PREFIX.length()) + final value = entry.value + // handle special type conversions + switch( fieldName ) { + case 'machineTypes': + // comma-separated string → List + merged.machineTypes = value instanceof String ? ((String) value).split(',').collect { it.trim() } : value + break + case 'diskEncrypted': + merged.diskEncrypted = value instanceof String ? Boolean.parseBoolean((String) value) : value + break + default: + merged.put(fieldName, value) + break + } + } + + return new MachineRequirementOpts(merged) + } + +} 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..985718339a --- /dev/null +++ b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy @@ -0,0 +1,228 @@ +/* + * 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([arch: 'x86_64']) + + when: + def result = HintHelper.overlayHints(base, [:]) + then: + result.arch == 'x86_64' + } + + def 'should return base opts when no seqera hints'() { + given: + def base = new MachineRequirementOpts([arch: 'x86_64']) + + when: + def result = HintHelper.overlayHints(base, [consumableResource: 'my-license']) + then: + result.arch == 'x86_64' + } + + def 'should overlay arch hint'() { + given: + def base = new MachineRequirementOpts([arch: 'x86_64']) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.arch': 'arm64']) + then: + result.arch == 'arm64' + } + + 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 from comma-separated string'() { + 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 from string'() { + 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([arch: 'x86_64', provisioning: 'ondemand']) + + when: + def result = HintHelper.overlayHints(base, [ + 'seqera/machineRequirement.arch': 'arm64', + 'seqera/machineRequirement.provisioning': 'spotFirst', + 'seqera/machineRequirement.maxSpotAttempts': 3, + 'seqera/machineRequirement.diskType': 'ebs/gp3' + ]) + then: + result.arch == 'arm64' + result.provisioning == 'spotFirst' + result.maxSpotAttempts == 3 + result.diskType == 'ebs/gp3' + } + + def 'should preserve base values not overridden by hints'() { + given: + def base = new MachineRequirementOpts([arch: 'x86_64', provisioning: 'spot', diskType: 'ebs/gp3']) + + when: + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.arch': 'arm64']) + then: + result.arch == 'arm64' + result.provisioning == 'spot' + result.diskType == 'ebs/gp3' + } + + 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([ + consumableResource: 'my-license', + 'k8s/scheduling.nodeSelector': 'gpu=true', + 'seqera/machineRequirement.arch': 'arm64' + ]) + then: + result.size() == 1 + result['machineRequirement.arch'] == 'arm64' + } + + def 'should handle null hints map'() { + when: + def result = HintHelper.extractSeqeraHints(null) + then: + result.isEmpty() + } + +} From 91da8a996202d375d2af91b31a79708f577ac08b Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 16 Apr 2026 14:57:40 -0500 Subject: [PATCH 05/16] update docs Signed-off-by: Ben Sherman --- docs/reference/process.md | 25 ++++++++++++ .../nextflow/processor/TaskConfig.groovy | 4 +- .../nextflow/script/ProcessConfig.groovy | 4 +- .../nextflow/script/dsl/ProcessBuilder.groovy | 38 +++++++++---------- .../java/nextflow/script/dsl/ProcessDsl.java | 7 ++++ .../nextflow/script/types/TaskConfig.java | 16 ++++---- 6 files changed, 63 insertions(+), 31 deletions(-) diff --git a/docs/reference/process.md b/docs/reference/process.md index bb6ecb0ebb..1c9fb9167e 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -840,6 +840,31 @@ The above example produces: [4, D] ``` +(process-hints)= + +### hints + +The `hints` directive specifies executor-specific scheduling hints as key-value pairs. Each executor uses the hints it recognizes and ignores the rest. For example: + +```nextflow +process hello { + hints consumableResources: 'my-license=1' + + script: + """ + your_command --here + """ +} +``` + +You can prefix a hint with the name of an executor to apply it only to that executor. For example: + +```nextflow + hints 'awsbatch/consumableResources': 'my-license=1' +``` + +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/TaskConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy index 1f79ac1c3b..f184e93f85 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy @@ -529,8 +529,8 @@ class TaskConfig extends LazyMap implements Cloneable { return CmdLineOptionMap.emptyOption() } - Map getHints() { - return get('hints') as Map ?: Collections.emptyMap() + Map getHints() { + return get('hints') as Map ?: Collections.emptyMap() } Map getResourceLabels() { diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy index a56feccced..624783914b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy @@ -173,8 +173,8 @@ class ProcessConfig implements Map, Cloneable { HashMode.of(configProperties.cache) ?: HashMode.DEFAULT() } - Map getHints() { - (configProperties.get('hints') ?: Collections.emptyMap()) as Map + Map getHints() { + (configProperties.get('hints') ?: Collections.emptyMap()) as Map } Map getResourceLabels() { 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 c2b6a6c66f..7bd1bf6812 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy @@ -225,6 +225,25 @@ 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 + + def allHints = (Map)config.get('hints') + if( !allHints ) { + allHints = [:] + } + allHints += map + config.put('hints', allHints) + } + /** * Implements the {@code label} directive. * @@ -378,25 +397,6 @@ class ProcessBuilder { config.put('resourceLabels', allLabels) } - /** - * 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 - - def allHints = (Map)config.get('hints') - if( !allHints ) { - allHints = [:] - } - allHints += map - config.put('hints', allHints) - } - private static final List VALID_RESOURCE_LIMITS = List.of('cpus', 'memory', 'disk', 'time') /** 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..efbb863ce9 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 scheduling hints as key-value pairs. + + [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 9e150ce60c..71cd81a629 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 scheduling hints as key-value pairs. + + [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. @@ -320,14 +328,6 @@ public interface TaskConfig { """) String getQueue(); - @Constant("hints") - @Description(""" - The `hints` directive allows you to specify executor-specific scheduling hints as key-value pairs. - - [Read more](https://nextflow.io/docs/latest/reference/process.html#hints) - """) - Map getHints(); - @Constant("resourceLabels") @Description(""" The `resourceLabels` directive allows you to specify custom name-value pairs which are applied to the compute resources used for the process execution. From 0cf2400d91d6bbf888882d87c6fa805f65d2d0a9 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 16 Apr 2026 15:00:36 -0500 Subject: [PATCH 06/16] Add awsbatch/consumableResources hint Signed-off-by: Ben Sherman --- docs/executor.md | 8 ++++ .../groovy/nextflow/processor/HintDefs.groovy | 2 +- .../nextflow/processor/HintDefsTest.groovy | 14 +++---- .../nextflow/processor/TaskConfigTest.groovy | 10 ++--- .../aws/batch/AwsBatchTaskHandler.groovy | 42 +++++++++++++++++++ .../model/RegisterJobDefinitionModel.groovy | 15 +++++++ .../aws/batch/AwsBatchTaskHandlerTest.groovy | 31 ++++++++++++++ .../test/io/seqera/util/HintHelperTest.groovy | 4 +- 8 files changed, 111 insertions(+), 15 deletions(-) diff --git a/docs/executor.md b/docs/executor.md index c62f0e21f3..2fc061255f 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)= diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy index f10b242f35..446aa5c3f6 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy @@ -35,7 +35,7 @@ class HintDefs { * validated by the target executor, not this registry. */ static final Map KNOWN_HINTS = [ - 'consumableResource': String + 'consumableResources': String ] /** diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy index 5c4f38524d..abe2836388 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy @@ -27,15 +27,15 @@ class HintDefsTest extends Specification { def 'should accept known hint key'() { when: - HintDefs.validateHints([consumableResource: 'my-license']) + HintDefs.validateHints([consumableResources: 'my-license']) then: noExceptionThrown() } def 'should warn on unknown key with close match'() { - // consumableResourc is close to consumableResource + // consumableResource is close to consumableResources when: - HintDefs.validateHints([consumableResourc: 'my-license']) + HintDefs.validateHints([consumableResource: 'my-license']) then: noExceptionThrown() // warning is logged — verified by log output in integration tests @@ -50,16 +50,16 @@ class HintDefsTest extends Specification { def 'should reject invalid value type'() { when: - HintDefs.validateHints([consumableResource: ['a', 'b']]) + HintDefs.validateHints([consumableResources: ['a', 'b']]) then: def e = thrown(IllegalArgumentException) e.message.contains('Invalid hint value type') - e.message.contains('consumableResource') + e.message.contains('consumableResources') } def 'should accept string and integer values'() { when: - HintDefs.validateHints([consumableResource: 'my-license', 'scheduling.priority': 10]) + HintDefs.validateHints([consumableResources: 'my-license', 'scheduling.priority': 10]) then: noExceptionThrown() } @@ -85,7 +85,7 @@ class HintDefsTest extends Specification { def 'should accept null hint value'() { when: - HintDefs.validateHints([consumableResource: null]) + HintDefs.validateHints([consumableResources: null]) then: noExceptionThrown() } diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy index 854c8937e9..9fa455b705 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy @@ -629,15 +629,15 @@ class TaskConfigTest extends Specification { when: def process = new ProcessConfig(script) def dsl = new ProcessBuilder(process) - dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license' ) + dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license=1' ) then: - process.get('hints') == ['seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license'] + process.get('hints') == ['seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license=1'] when: def config = process.createTaskConfig() then: - config.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license'] + config.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license=1'] } def 'should return empty map when no hints set'() { @@ -654,9 +654,9 @@ class TaskConfigTest extends Specification { when: 'set hints in process definition' def process = new ProcessConfig(script) def dsl = new ProcessBuilder(process) - dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license' ) + dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license' ) then: - process.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResource: 'my-license'] + process.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license'] when: 'config override replaces the entire map' def config = process.createTaskConfig() 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..b1852133f5 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 hints) { + if( !hints ) + return null + final List resourceList = new ArrayList<>() + for( final key : hints.keySet() ) { + if( !isHint(key, 'consumableResources') ) + continue + final entries = (hints[key] ?: '').tokenize(',') + for( final entry : entries ) { + final index = entry.indexOf('=') + final resourceName = entry.substring(0, index) + final resourceQuantity = entry.substring(index + 1).toLong() + final resource = ConsumableResourceRequirement.builder() + .consumableResource(resourceName) + .quantity(resourceQuantity) + .build() + resourceList.add(resource) + } + } + if( !resourceList ) + return null + return ConsumableResourceProperties.builder() + .consumableResourceList(resourceList) + .build() + } + + @CompileStatic + protected boolean isHint(String key, String name) { + return key == name || key == "awsbatch/${name}".toString() + } + /** * 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..d9770ca838 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() @@ -121,6 +133,8 @@ class RegisterJobDefinitionModel { builder.parameters(parameters) if (tags) builder.tags(tags) + if (consumableResourceProperties) + builder.consumableResourceProperties(consumableResourceProperties) return (RegisterJobDefinitionRequest) builder.build() } @@ -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..d01a2bf429 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,37 @@ 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: + def IMAGE = 'foo/bar:1.0' + def JOB_NAME = 'nf-foo-bar-1-0' + def HINTS = [consumableResources: 'license-a=1,license-b=2', foo: 'bar'] + def task = Mock(TaskRun) { + getContainer() >> IMAGE + getConfig() >> Mock(TaskConfig) { + getHints() >> HINTS + } + } + 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 create a fargate job definition' () { diff --git a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy index 985718339a..fb8dc0d9db 100644 --- a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy @@ -42,7 +42,7 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([arch: 'x86_64']) when: - def result = HintHelper.overlayHints(base, [consumableResource: 'my-license']) + def result = HintHelper.overlayHints(base, [consumableResources: 'my-license']) then: result.arch == 'x86_64' } @@ -209,7 +209,7 @@ class HintHelperTest extends Specification { def 'should ignore non-seqera hints in extraction'() { when: def result = HintHelper.extractSeqeraHints([ - consumableResource: 'my-license', + consumableResources: 'my-license', 'k8s/scheduling.nodeSelector': 'gpu=true', 'seqera/machineRequirement.arch': 'arm64' ]) From 570706c304dd1509d95c0f78625226ff1413a7de Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 19 Apr 2026 21:41:52 +0200 Subject: [PATCH 07/16] Fix HintHelper signatures to match TaskConfig.getHints() [ci fast] TaskConfig.getHints() returns Map, but HintHelper declared Map, causing @CompileStatic compile errors in SeqeraTaskHandler. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Paolo Di Tommaso --- .../src/main/io/seqera/util/HintHelper.groovy | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy index 7eb8298a7f..219d69d1c2 100644 --- a/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy +++ b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy @@ -57,12 +57,12 @@ class HintHelper { * @param hints the full hints map from task config * @return a map of hint keys (with {@code seqera/} prefix stripped) to values */ - static Map extractSeqeraHints(Map hints) { + static Map extractSeqeraHints(Map hints) { if( !hints ) return Collections.emptyMap() - final result = new LinkedHashMap() - for( Map.Entry entry : hints.entrySet() ) { + final result = new LinkedHashMap() + for( Map.Entry entry : hints.entrySet() ) { final key = entry.key if( !key.startsWith(PREFIX) ) continue @@ -84,7 +84,7 @@ class HintHelper { * @param hints the full hints map from task config * @return a new {@link MachineRequirementOpts} with hints overlaid */ - static MachineRequirementOpts overlayHints(MachineRequirementOpts baseOpts, Map hints) { + static MachineRequirementOpts overlayHints(MachineRequirementOpts baseOpts, Map hints) { final seqeraHints = extractSeqeraHints(hints) if( !seqeraHints ) return baseOpts @@ -107,17 +107,17 @@ class HintHelper { if( baseOpts.capacityMode ) merged.capacityMode = baseOpts.capacityMode // overlay hints — strip the machineRequirement. prefix to get field names - for( Map.Entry entry : seqeraHints.entrySet() ) { + for( Map.Entry entry : seqeraHints.entrySet() ) { final fieldName = entry.key.substring(MR_PREFIX.length()) final value = entry.value // handle special type conversions switch( fieldName ) { case 'machineTypes': // comma-separated string → List - merged.machineTypes = value instanceof String ? ((String) value).split(',').collect { it.trim() } : value + merged.machineTypes = value.split(',').collect { it.trim() } break case 'diskEncrypted': - merged.diskEncrypted = value instanceof String ? Boolean.parseBoolean((String) value) : value + merged.diskEncrypted = Boolean.parseBoolean(value) break default: merged.put(fieldName, value) From 8c4bbfc407b108b8bd0766dbb0ee290c0c9f6489 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sun, 19 Apr 2026 22:21:51 +0200 Subject: [PATCH 08/16] Use String hint values in HintHelperTest to match production path [ci fast] TaskConfig.getHints() always delivers Map; tests now reflect that contract instead of relying on generics erasure. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Paolo Di Tommaso --- .../src/test/io/seqera/util/HintHelperTest.groovy | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy index fb8dc0d9db..698d83a30d 100644 --- a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy @@ -72,7 +72,7 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.maxSpotAttempts': 3]) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.maxSpotAttempts': '3']) then: result.maxSpotAttempts == 3 } @@ -102,7 +102,7 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskThroughputMiBps': 500]) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskThroughputMiBps': '500']) then: result.diskThroughputMiBps == 500 } @@ -112,7 +112,7 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskIops': 10000]) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskIops': '10000']) then: result.diskIops == 10000 } @@ -175,7 +175,7 @@ class HintHelperTest extends Specification { def result = HintHelper.overlayHints(base, [ 'seqera/machineRequirement.arch': 'arm64', 'seqera/machineRequirement.provisioning': 'spotFirst', - 'seqera/machineRequirement.maxSpotAttempts': 3, + 'seqera/machineRequirement.maxSpotAttempts': '3', 'seqera/machineRequirement.diskType': 'ebs/gp3' ]) then: From 779974a64052b57892de84d8abb1a4d8f386ea02 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Wed, 22 Apr 2026 22:14:23 +0200 Subject: [PATCH 09/16] Address code review for hints directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce Map shape; remove AWS-specific KNOWN_HINTS leak from core HintDefs. Core validates shape only; executors validate their own namespaces. - AWS Batch: harden consumableResources parser with clear errors, prefer awsbatch/-prefixed key, warn on unknown awsbatch/* keys. - Seqera: derive known hint keys from MachineRequirementOpts via @ConfigOption reflection; overlay preserves all declared fields. Accept unprefixed machineRequirement.* keys too — prefixed wins on collision. Drop dangling references to the removed `arch` field. - Replace Mock(TaskConfig) integration test in AWS Batch with a real ProcessBuilder → TaskConfig path; add compact SeqeraTaskHandler submit tests covering hint overlay and foreign-namespace passthrough. - Docs: spell out unprefixed-applies-to-any-executor semantics; list all Seqera-supported hints. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Paolo Di Tommaso --- docs/executor.md | 29 +++++ docs/reference/process.md | 10 +- .../groovy/nextflow/processor/HintDefs.groovy | 63 ++++------- .../nextflow/script/dsl/ProcessBuilder.groovy | 2 + .../nextflow/processor/HintDefsTest.groovy | 62 +++++------ .../nextflow/processor/TaskConfigTest.groovy | 4 +- .../script/dsl/ProcessBuilderTest.groovy | 19 ++-- .../java/nextflow/script/dsl/ProcessDsl.java | 2 +- .../nextflow/script/types/TaskConfig.java | 2 +- .../aws/batch/AwsBatchTaskHandler.groovy | 48 +++++--- .../model/RegisterJobDefinitionModel.groovy | 4 +- .../aws/batch/AwsBatchTaskHandlerTest.groovy | 87 ++++++++++++++- .../src/main/io/seqera/util/HintHelper.groovy | 104 +++++++++--------- .../executor/SeqeraTaskHandlerTest.groovy | 73 ++++++++++++ .../test/io/seqera/util/HintHelperTest.groovy | 87 +++++++++++---- 15 files changed, 417 insertions(+), 179 deletions(-) diff --git a/docs/executor.md b/docs/executor.md index 2fc061255f..d2a9cd06bb 100644 --- a/docs/executor.md +++ b/docs/executor.md @@ -449,6 +449,35 @@ Resource requests and other job characteristics can be controlled via the follow - {ref}`process-memory` - {ref}`process-time` +The following {ref}`hints ` are supported. Each hint overrides the corresponding field of the `seqera.executor.machineRequirement` config scope on a per-process basis. Values must be strings; keys may be used as-is or with the `seqera/` prefix to restrict them to this executor. + +| Hint key | Description | +| --- | --- | +| `machineRequirement.capacityMode` | ECS capacity provider mode: `managed` or `asg`. | +| `machineRequirement.diskAllocation` | Disk allocation strategy: `task` or `node`. | +| `machineRequirement.diskEncrypted` | `true` or `false` — enable KMS encryption for the EBS volume. | +| `machineRequirement.diskIops` | IOPS for io1/io2/gp3 volumes. | +| `machineRequirement.diskMountPath` | Container mount path for the task disk (e.g. `/data`). | +| `machineRequirement.diskSize` | Disk size, e.g. `100.GB`. | +| `machineRequirement.diskThroughputMiBps` | Throughput (MiB/s) for gp3 volumes. | +| `machineRequirement.diskType` | EBS volume type (e.g. `ebs/gp3`, `ebs/io1`). | +| `machineRequirement.machineTypes` | Comma-separated list of acceptable machine type patterns (e.g. `m5,m5a,c6i.large`). | +| `machineRequirement.maxSpotAttempts` | Maximum spot retry attempts before falling back to on-demand. | +| `machineRequirement.provisioning` | Instance provisioning mode: `spot`, `ondemand`, or `spotFirst`. | + +For example, to override the provisioning mode for a single process: + +```nextflow +process hello { + hints 'seqera/machineRequirement.provisioning': 'spotFirst' + + script: + """ + your_command --here + """ +} +``` + ### 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/process.md b/docs/reference/process.md index a533fd6c6e..9e1ea9318c 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -844,7 +844,9 @@ The above example produces: ### hints -The `hints` directive specifies executor-specific scheduling hints as key-value pairs. Each executor uses the hints it recognizes and ignores the rest. For example: +The `hints` directive specifies executor-specific hints as key-value pairs. Each executor uses the hints it recognizes and ignores the rest. Hint values must be strings. + +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 { @@ -857,12 +859,16 @@ process hello { } ``` -You can prefix a hint with the name of an executor to apply it only to that executor. For example: +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)= diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy index 446aa5c3f6..f77bd86dc9 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy @@ -17,66 +17,51 @@ package nextflow.processor import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j /** - * Defines the global hint key registry and provides validation - * for the {@code hints} process directive. + * 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}. * * @author Paolo Di Tommaso */ -@Slf4j @CompileStatic class HintDefs { /** - * Global registry of known unprefixed hint keys and their expected value types. - * Executor-prefixed keys (e.g. {@code seqera/machineRequirement.arch}) are - * validated by the target executor, not this registry. - */ - static final Map KNOWN_HINTS = [ - 'consumableResources': String - ] - - /** - * Validates the given hints map against the global registry. - *

- * Unprefixed keys are checked against {@link #KNOWN_HINTS}: + * Validates the hint map structure. Does not check whether keys are + * recognized — that is the responsibility of each executor. + * + * Rules: *

    - *
  • Unknown keys produce a warning with a "did you mean?" suggestion if a close match exists
  • - *
  • Value types are validated (must resolve to String or Integer)
  • + *
  • keys must be non-empty
  • + *
  • keys may contain at most one {@code /} separating the optional + * executor namespace from the hint name
  • + *
  • values must be {@code String} or {@code null}
  • *
- * Executor-prefixed keys (containing {@code /}) are skipped — they are validated by the target executor. * - * @param hints the resolved hints map + * @param hints the hint map to validate (may be {@code null}) + * @throws IllegalArgumentException if the map is malformed */ - static void validateHints(Map hints) { + static void validateHints(Map hints) { if( !hints ) return - for( Map.Entry entry : hints.entrySet() ) { + for( Map.Entry entry : hints.entrySet() ) { final key = entry.key final value = entry.value - // skip executor-prefixed keys — validated by the executor - if( key.contains('/') ) - continue + if( !key ) + throw new IllegalArgumentException("Process hint key cannot be null or empty") - // validate value type - if( value != null && !(value instanceof String) && !(value instanceof Integer) ) { - throw new IllegalArgumentException("Invalid hint value type for key '${key}': expected String or Integer, got ${value.getClass().getName()}") - } + if( key.count('/') > 1 ) + throw new IllegalArgumentException("Invalid hint key '${key}': expected 'name' or 'executor/name'") - // validate key against registry - if( !KNOWN_HINTS.containsKey(key) ) { - final suggestions = KNOWN_HINTS.keySet().toList().closest(key) - if( suggestions ) { - log.warn "Unknown process hint: '${key}' — did you mean '${suggestions.first()}'?" - } - else { - log.warn "Unknown process hint: '${key}'" - } - } + if( value != null && !(value instanceof CharSequence) ) + throw new IllegalArgumentException("Invalid hint value for key '${key}': expected String, got ${value.getClass().getName()}") } } 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 7bd1bf6812..5d26298875 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 @@ -235,6 +236,7 @@ class ProcessBuilder { */ void hints(Map map) { if( !map ) return + HintDefs.validateHints(map) def allHints = (Map)config.get('hints') if( !allHints ) { diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy index abe2836388..8953890d9a 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy @@ -25,69 +25,67 @@ import spock.lang.Specification */ class HintDefsTest extends Specification { - def 'should accept known hint key'() { + def 'should accept valid hints'() { when: - HintDefs.validateHints([consumableResources: 'my-license']) + HintDefs.validateHints([ + consumableResources: 'my-license=1', + 'awsbatch/consumableResources': 'a=1,b=2', + 'seqera/machineRequirement.provisioning': 'spot', + ]) then: noExceptionThrown() } - def 'should warn on unknown key with close match'() { - // consumableResource is close to consumableResources - when: - HintDefs.validateHints([consumableResource: 'my-license']) - then: - noExceptionThrown() - // warning is logged — verified by log output in integration tests + def 'should accept null and empty maps'() { + expect: + HintDefs.validateHints(null) == null + HintDefs.validateHints([:]) == null } - def 'should warn on unknown key with no close match'() { + def 'should accept null hint value'() { when: - HintDefs.validateHints([somethingRandom: 'value']) + HintDefs.validateHints([consumableResources: null]) then: noExceptionThrown() } - def 'should reject invalid value type'() { + def 'should reject non-string value'() { when: - HintDefs.validateHints([consumableResources: ['a', 'b']]) + HintDefs.validateHints([consumableResources: 42]) then: def e = thrown(IllegalArgumentException) - e.message.contains('Invalid hint value type') - e.message.contains('consumableResources') + e.message.contains("Invalid hint value") + e.message.contains("consumableResources") } - def 'should accept string and integer values'() { + def 'should reject list value'() { when: - HintDefs.validateHints([consumableResources: 'my-license', 'scheduling.priority': 10]) + HintDefs.validateHints([consumableResources: ['a', 'b']]) then: - noExceptionThrown() + thrown(IllegalArgumentException) } - def 'should skip executor-prefixed keys'() { + def 'should reject closure value'() { when: - HintDefs.validateHints(['seqera/machineRequirement.arch': 'arm64', 'seqera/unknownKey': 'value']) + HintDefs.validateHints([consumableResources: { 'x' }]) then: - noExceptionThrown() + thrown(IllegalArgumentException) } - def 'should handle null and empty maps'() { + def 'should reject empty key'() { when: - HintDefs.validateHints(null) + HintDefs.validateHints(['': 'x']) then: - noExceptionThrown() - - when: - HintDefs.validateHints([:]) - then: - noExceptionThrown() + def e = thrown(IllegalArgumentException) + e.message.contains("null or empty") } - def 'should accept null hint value'() { + def 'should reject multi-segment key'() { when: - HintDefs.validateHints([consumableResources: null]) + HintDefs.validateHints(['a/b/c': 'x']) then: - noExceptionThrown() + 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 9fa455b705..9727033c9b 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy @@ -660,9 +660,9 @@ class TaskConfigTest extends Specification { when: 'config override replaces the entire map' def config = process.createTaskConfig() - config.put('hints', ['scheduling.priority': 5]) + config.put('hints', ['scheduling.priority': '5']) then: - config.getHints() == ['scheduling.priority': 5] + config.getHints() == ['scheduling.priority': '5'] } def 'should report error on negative cpus' () { 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 eaa33f16aa..83fe47545f 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy @@ -184,26 +184,29 @@ class ProcessBuilderTest extends Specification { config.getHints() == ['seqera/machineRequirement.arch': 'arm64'] when: - builder.hints 'seqera/machineRequirement.provisioning': 'spot', 'seqera/machineRequirement.maxSpotAttempts': 3 + 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] + 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] + config.getHints() == ['seqera/machineRequirement.arch': 'x86_64', 'seqera/machineRequirement.provisioning': 'spot', 'seqera/machineRequirement.maxSpotAttempts': '3'] } - def 'should store closure values in hints' () { + def 'should reject non-string hint values' () { given: def builder = createBuilder() - def config = builder.getConfig() when: - def closure = { 'spot' } - builder.hints 'seqera/machineRequirement.provisioning': closure + builder.hints 'seqera/machineRequirement.provisioning': { 'spot' } + then: + thrown(IllegalArgumentException) + + when: + builder.hints 'seqera/machineRequirement.maxSpotAttempts': 3 then: - config.getHints()['seqera/machineRequirement.provisioning'].is(closure) + thrown(IllegalArgumentException) } def 'should check a valid label' () { 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 efbb863ce9..ed5f7e407d 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 @@ -194,7 +194,7 @@ void disk( void fair(Boolean value); @Description(""" - The `hints` directive specifies executor-specific scheduling hints as key-value pairs. + 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) """) 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 71cd81a629..9e170ce240 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 @@ -226,7 +226,7 @@ public interface TaskConfig { @Constant("hints") @Description(""" - The `hints` directive specifies executor-specific scheduling hints as key-value pairs. + 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) """) 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 b1852133f5..f7e22f636f 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 @@ -686,25 +686,40 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler KNOWN_HINTS = Set.of('consumableResources') + static private 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?.trim() ) + return null final List resourceList = new ArrayList<>() - for( final key : hints.keySet() ) { - if( !isHint(key, 'consumableResources') ) + for( final token : raw.tokenize(',') ) { + final entry = token.trim() + if( !entry ) continue - final entries = (hints[key] ?: '').tokenize(',') - for( final entry : entries ) { - final index = entry.indexOf('=') - final resourceName = entry.substring(0, index) - final resourceQuantity = entry.substring(index + 1).toLong() - final resource = ConsumableResourceRequirement.builder() - .consumableResource(resourceName) - .quantity(resourceQuantity) - .build() - resourceList.add(resource) + final index = entry.indexOf('=') + if( index <= 0 || index == entry.length()-1 ) + throw new IllegalArgumentException("Invalid 'consumableResources' hint entry '${entry}' — expected 'name=quantity'") + final resourceName = entry.substring(0, index).trim() + final qtyStr = entry.substring(index+1).trim() + final long resourceQuantity + try { + resourceQuantity = qtyStr.toLong() } + catch( NumberFormatException e ) { + throw new IllegalArgumentException("Invalid 'consumableResources' hint entry '${entry}' — quantity must be an integer") + } + resourceList.add( ConsumableResourceRequirement.builder() + .consumableResource(resourceName) + .quantity(resourceQuantity) + .build() ) } if( !resourceList ) return null @@ -714,8 +729,13 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler 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}") + } } /** 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 d9770ca838..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 @@ -129,12 +129,12 @@ class RegisterJobDefinitionModel { builder.platformCapabilities(platformCapabilities) if (containerProperties) builder.containerProperties(containerProperties.toBatchContainerProperties()) + if (consumableResourceProperties) + builder.consumableResourceProperties(consumableResourceProperties) if (parameters) builder.parameters(parameters) if (tags) builder.tags(tags) - if (consumableResourceProperties) - builder.consumableResourceProperties(consumableResourceProperties) return (RegisterJobDefinitionRequest) builder.build() } 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 d01a2bf429..c3f7d0cc15 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 @@ -601,16 +601,20 @@ class AwsBatchTaskHandlerTest extends Specification { result.consumableResourceProperties == null } - def 'should create a job definition with consumable resources from hints' () { - given: + def 'should create a job definition with consumable resources from hints (built from real ProcessBuilder)' () { + 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 HINTS = [consumableResources: 'license-a=1,license-b=2', foo: 'bar'] def task = Mock(TaskRun) { getContainer() >> IMAGE - getConfig() >> Mock(TaskConfig) { - getHints() >> HINTS - } + getConfig() >> taskConfig } def handler = Spy(AwsBatchTaskHandler) { getTask() >> task @@ -631,6 +635,77 @@ class AwsBatchTaskHandlerTest extends Specification { 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 + handler.getConsumableResources([consumableResources: ' ']) == null + } + + def 'should tolerate whitespace in consumableResources entries' () { + given: + def handler = new AwsBatchTaskHandler() + + when: + def result = handler.getConsumableResources([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 fail with a clear error on invalid consumableResources entry' () { + given: + def handler = new AwsBatchTaskHandler() + + when: + handler.getConsumableResources([consumableResources: 'bad']) + then: + def e = thrown(IllegalArgumentException) + e.message.contains("'bad'") + e.message.contains("name=quantity") + + when: + handler.getConsumableResources([consumableResources: 'license-a=not-a-number']) + then: + def e2 = thrown(IllegalArgumentException) + e2.message.contains("quantity must be an integer") + + when: + handler.getConsumableResources([consumableResources: '=1']) + then: + thrown(IllegalArgumentException) + + when: + handler.getConsumableResources([consumableResources: 'license-a=']) + then: + thrown(IllegalArgumentException) + } + def 'should create a fargate job definition' () { given: def ARM64 = new Architecture('linux/arm64') diff --git a/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy index 219d69d1c2..d8a48365ad 100644 --- a/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy +++ b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy @@ -16,8 +16,11 @@ 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 @@ -32,53 +35,65 @@ class HintHelper { static final String PREFIX = 'seqera/' static final String MR_PREFIX = 'machineRequirement.' - /** - * Known {@code seqera/machineRequirement.*} hint keys (after stripping the {@code seqera/} prefix). - */ - static final Set KNOWN_KEYS = Set.of( - 'machineRequirement.arch', - 'machineRequirement.provisioning', - 'machineRequirement.maxSpotAttempts', - 'machineRequirement.machineTypes', - 'machineRequirement.diskType', - 'machineRequirement.diskThroughputMiBps', - 'machineRequirement.diskIops', - 'machineRequirement.diskEncrypted', - 'machineRequirement.diskAllocation', - 'machineRequirement.diskMountPath', - 'machineRequirement.diskSize', - 'machineRequirement.capacityMode' + 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 {@code seqera/}-prefixed hints from the hints map and validate them. - * Raises an error for unrecognized {@code seqera/} keys. + * 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 hint keys (with {@code seqera/} prefix stripped) to values + * @return a map of known hint names (no prefix) to values */ static Map extractSeqeraHints(Map hints) { if( !hints ) return Collections.emptyMap() - final result = new LinkedHashMap() + final unprefixed = new LinkedHashMap() + final prefixed = new LinkedHashMap() for( Map.Entry entry : hints.entrySet() ) { final key = entry.key - if( !key.startsWith(PREFIX) ) + if( !key ) continue - final stripped = key.substring(PREFIX.length()) - if( !KNOWN_KEYS.contains(stripped) ) { - throw new IllegalArgumentException("Unknown Seqera Platform hint: '${key}' — supported keys are: ${KNOWN_KEYS.collect { PREFIX + it }.sort().join(', ')}") + 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) } - result.put(stripped, entry.value) } - return result + + unprefixed.putAll(prefixed) + return unprefixed } /** - * Overlay {@code seqera/machineRequirement.*} hints onto existing config-scope - * {@link MachineRequirementOpts}. Hint values take precedence over config-scope values. + * 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 @@ -89,39 +104,30 @@ class HintHelper { if( !seqeraHints ) return baseOpts - // build a map from current opts, then overlay hints final Map merged = new LinkedHashMap<>() + for( final field : MR_FIELDS ) { + final value = field.get(baseOpts) + if( value != null ) + merged.put(field.name, value) + } - // copy existing config-scope values - if( baseOpts.arch ) merged.arch = baseOpts.arch - if( baseOpts.provisioning ) merged.provisioning = baseOpts.provisioning - if( baseOpts.maxSpotAttempts != null ) merged.maxSpotAttempts = baseOpts.maxSpotAttempts - if( baseOpts.machineTypes ) merged.machineTypes = baseOpts.machineTypes - if( baseOpts.diskType ) merged.diskType = baseOpts.diskType - if( baseOpts.diskThroughputMiBps != null ) merged.diskThroughputMiBps = baseOpts.diskThroughputMiBps - if( baseOpts.diskIops != null ) merged.diskIops = baseOpts.diskIops - if( baseOpts.diskEncrypted != null ) merged.diskEncrypted = baseOpts.diskEncrypted - if( baseOpts.diskAllocation ) merged.diskAllocation = baseOpts.diskAllocation - if( baseOpts.diskMountPath ) merged.diskMountPath = baseOpts.diskMountPath - if( baseOpts.diskSize ) merged.diskSize = baseOpts.diskSize.toString() - if( baseOpts.capacityMode ) merged.capacityMode = baseOpts.capacityMode - - // overlay hints — strip the machineRequirement. prefix to get field names for( Map.Entry entry : seqeraHints.entrySet() ) { final fieldName = entry.key.substring(MR_PREFIX.length()) final value = entry.value - // handle special type conversions + if( value == null ) { + merged.remove(fieldName) + continue + } + // special-case types that need parsing before the MachineRequirementOpts constructor can coerce them switch( fieldName ) { case 'machineTypes': - // comma-separated string → List - merged.machineTypes = value.split(',').collect { it.trim() } + merged.put(fieldName, value.split(',').collect { it.trim() }) break case 'diskEncrypted': - merged.diskEncrypted = Boolean.parseBoolean(value) + merged.put(fieldName, Boolean.parseBoolean(value)) break default: merged.put(fieldName, value) - break } } 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..91f80f6319 100644 --- a/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy @@ -20,7 +20,9 @@ import com.google.common.hash.HashCode import io.seqera.config.ExecutorOpts import io.seqera.sched.api.schema.v1a1.DescribeTaskResponse import io.seqera.sched.api.schema.v1a1.GetTaskLogsResponse +import io.seqera.config.MachineRequirementOpts import io.seqera.sched.api.schema.v1a1.MachineInfo +import io.seqera.sched.api.schema.v1a1.ProvisioningModel 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.ResourceLimit @@ -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 index 698d83a30d..291d296624 100644 --- a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy @@ -29,32 +29,22 @@ class HintHelperTest extends Specification { def 'should return base opts when no hints'() { given: - def base = new MachineRequirementOpts([arch: 'x86_64']) + def base = new MachineRequirementOpts([provisioning: 'spot']) when: def result = HintHelper.overlayHints(base, [:]) then: - result.arch == 'x86_64' + result.provisioning == 'spot' } def 'should return base opts when no seqera hints'() { given: - def base = new MachineRequirementOpts([arch: 'x86_64']) + def base = new MachineRequirementOpts([provisioning: 'spot']) when: def result = HintHelper.overlayHints(base, [consumableResources: 'my-license']) then: - result.arch == 'x86_64' - } - - def 'should overlay arch hint'() { - given: - def base = new MachineRequirementOpts([arch: 'x86_64']) - - when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.arch': 'arm64']) - then: - result.arch == 'arm64' + result.provisioning == 'spot' } def 'should overlay provisioning hint'() { @@ -169,17 +159,15 @@ class HintHelperTest extends Specification { def 'should overlay multiple hints at once'() { given: - def base = new MachineRequirementOpts([arch: 'x86_64', provisioning: 'ondemand']) + def base = new MachineRequirementOpts([provisioning: 'ondemand']) when: def result = HintHelper.overlayHints(base, [ - 'seqera/machineRequirement.arch': 'arm64', 'seqera/machineRequirement.provisioning': 'spotFirst', 'seqera/machineRequirement.maxSpotAttempts': '3', 'seqera/machineRequirement.diskType': 'ebs/gp3' ]) then: - result.arch == 'arm64' result.provisioning == 'spotFirst' result.maxSpotAttempts == 3 result.diskType == 'ebs/gp3' @@ -187,14 +175,24 @@ class HintHelperTest extends Specification { def 'should preserve base values not overridden by hints'() { given: - def base = new MachineRequirementOpts([arch: 'x86_64', provisioning: 'spot', diskType: 'ebs/gp3']) + def base = new MachineRequirementOpts([provisioning: 'spot', diskType: 'ebs/gp3', diskMountPath: '/data']) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.arch': 'arm64']) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskType': 'ebs/io1']) then: - result.arch == 'arm64' result.provisioning == 'spot' - result.diskType == 'ebs/gp3' + 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'() { @@ -211,11 +209,11 @@ class HintHelperTest extends Specification { def result = HintHelper.extractSeqeraHints([ consumableResources: 'my-license', 'k8s/scheduling.nodeSelector': 'gpu=true', - 'seqera/machineRequirement.arch': 'arm64' + 'seqera/machineRequirement.provisioning': 'spot' ]) then: result.size() == 1 - result['machineRequirement.arch'] == 'arm64' + result['machineRequirement.provisioning'] == 'spot' } def 'should handle null hints map'() { @@ -225,4 +223,47 @@ class HintHelperTest extends Specification { 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' + } + } From 1b88bdbe17cdd63056aa5fbd3a49bfc6d6283538 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 23 Apr 2026 15:25:56 -0500 Subject: [PATCH 10/16] minor edits Signed-off-by: Ben Sherman --- docs/executor.md | 26 +++++++++---------- docs/reference/process.md | 2 +- .../aws/batch/AwsBatchTaskHandler.groovy | 12 ++++----- .../aws/batch/AwsBatchTaskHandlerTest.groovy | 2 +- .../executor/SeqeraTaskHandlerTest.groovy | 4 +-- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/executor.md b/docs/executor.md index d2a9cd06bb..d6c4f2b67b 100644 --- a/docs/executor.md +++ b/docs/executor.md @@ -451,19 +451,17 @@ Resource requests and other job characteristics can be controlled via the follow The following {ref}`hints ` are supported. Each hint overrides the corresponding field of the `seqera.executor.machineRequirement` config scope on a per-process basis. Values must be strings; keys may be used as-is or with the `seqera/` prefix to restrict them to this executor. -| Hint key | Description | -| --- | --- | -| `machineRequirement.capacityMode` | ECS capacity provider mode: `managed` or `asg`. | -| `machineRequirement.diskAllocation` | Disk allocation strategy: `task` or `node`. | -| `machineRequirement.diskEncrypted` | `true` or `false` — enable KMS encryption for the EBS volume. | -| `machineRequirement.diskIops` | IOPS for io1/io2/gp3 volumes. | -| `machineRequirement.diskMountPath` | Container mount path for the task disk (e.g. `/data`). | -| `machineRequirement.diskSize` | Disk size, e.g. `100.GB`. | -| `machineRequirement.diskThroughputMiBps` | Throughput (MiB/s) for gp3 volumes. | -| `machineRequirement.diskType` | EBS volume type (e.g. `ebs/gp3`, `ebs/io1`). | -| `machineRequirement.machineTypes` | Comma-separated list of acceptable machine type patterns (e.g. `m5,m5a,c6i.large`). | -| `machineRequirement.maxSpotAttempts` | Maximum spot retry attempts before falling back to on-demand. | -| `machineRequirement.provisioning` | Instance provisioning mode: `spot`, `ondemand`, or `spotFirst`. | +- `machineRequirement.capacityMode` +- `machineRequirement.diskAllocation` +- `machineRequirement.diskEncrypted` +- `machineRequirement.diskIops` +- `machineRequirement.diskMountPath` +- `machineRequirement.diskSize` +- `machineRequirement.diskThroughputMiBps` +- `machineRequirement.diskType` +- `machineRequirement.machineTypes` +- `machineRequirement.maxSpotAttempts` +- `machineRequirement.provisioning` For example, to override the provisioning mode for a single process: @@ -478,6 +476,8 @@ process hello { } ``` +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/process.md b/docs/reference/process.md index 9e1ea9318c..f66e5736fc 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -846,7 +846,7 @@ The above example produces: The `hints` directive specifies executor-specific hints as key-value pairs. Each executor uses the hints it recognizes and ignores the rest. Hint values must be strings. -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: +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 { 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 f7e22f636f..2b4b26907e 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 @@ -686,9 +686,9 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler KNOWN_HINTS = Set.of('consumableResources') - static private final String SUPPORTED_HINTS_MSG = + private static final String HINT_PREFIX = 'awsbatch/' + private static final Set KNOWN_HINTS = Set.of('consumableResources') + private static final String SUPPORTED_HINTS_MSG = KNOWN_HINTS.collect { HINT_PREFIX + it }.sort().join(', ') @CompileStatic @@ -706,7 +706,7 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler Date: Thu, 23 Apr 2026 15:36:06 -0500 Subject: [PATCH 11/16] Fix ordering of seqera executor config options Signed-off-by: Ben Sherman --- docs/executor.md | 4 +++- docs/reference/config.md | 48 ++++++++++++++++++++-------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docs/executor.md b/docs/executor.md index d6c4f2b67b..eee980f9f8 100644 --- a/docs/executor.md +++ b/docs/executor.md @@ -449,7 +449,7 @@ Resource requests and other job characteristics can be controlled via the follow - {ref}`process-memory` - {ref}`process-time` -The following {ref}`hints ` are supported. Each hint overrides the corresponding field of the `seqera.executor.machineRequirement` config scope on a per-process basis. Values must be strings; keys may be used as-is or with the `seqera/` prefix to restrict them to this executor. +The following {ref}`hints ` are supported: - `machineRequirement.capacityMode` - `machineRequirement.diskAllocation` @@ -463,6 +463,8 @@ The following {ref}`hints ` are supported. Each hint overrides th - `machineRequirement.maxSpotAttempts` - `machineRequirement.provisioning` +Each hint overrides the corresponding field of the `seqera.executor.machineRequirement` config scope on a per-process basis. Values must be strings; 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 diff --git a/docs/reference/config.md b/docs/reference/config.md index 6519a7764d..793c226e91 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, e.g. `['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'`). From 43d69fcaced2c25dcb8e0d86d2b4d6b00bc799a5 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Fri, 24 Apr 2026 12:52:52 -0500 Subject: [PATCH 12/16] Allow hint values to be any raw data type Signed-off-by: Ben Sherman --- adr/20260323-hints-process-directive.md | 30 ++++++------ docs/executor.md | 4 +- docs/reference/process.md | 6 +-- .../groovy/nextflow/processor/HintDefs.groovy | 23 ++++++--- .../nextflow/processor/TaskConfig.groovy | 4 +- .../nextflow/script/ProcessConfig.groovy | 4 +- .../nextflow/script/dsl/ProcessBuilder.groovy | 2 +- .../nextflow/processor/HintDefsTest.groovy | 31 ++++-------- .../nextflow/processor/TaskConfigTest.groovy | 14 +++--- .../script/dsl/ProcessBuilderTest.groovy | 10 +++- .../java/nextflow/script/dsl/ProcessDsl.java | 2 +- .../nextflow/script/types/TaskConfig.java | 2 +- .../aws/batch/AwsBatchTaskHandler.groovy | 34 ++++++------- .../aws/batch/AwsBatchTaskHandlerTest.groovy | 49 +++++-------------- .../src/main/io/seqera/util/HintHelper.groovy | 24 +++------ .../executor/SeqeraTaskHandlerTest.groovy | 2 +- .../test/io/seqera/util/HintHelperTest.groovy | 16 +++--- 17 files changed, 111 insertions(+), 146 deletions(-) diff --git a/adr/20260323-hints-process-directive.md b/adr/20260323-hints-process-directive.md index be3ebd76db..504234b61b 100644 --- a/adr/20260323-hints-process-directive.md +++ b/adr/20260323-hints-process-directive.md @@ -63,7 +63,7 @@ The `hints` directive accepts a map of key-value pairs: process runDragen { cpus 4 memory '16 GB' - hints consumableResources: 'my-dragen-license=1,other-license=2' + hints consumableResources: ['my-dragen-license': 1, 'other-license': 2] script: """ @@ -77,22 +77,22 @@ process runDragen { process { withName: 'runDragen' { hints = [ - consumableResources: 'my-dragen-license=1,other-license=2' + consumableResources: ['my-dragen-license': 1, 'other-license': 2] ] } } ``` -Both keys and values are arbitrary strings. Executors are responsible for defining which hints they recognize, as well as the expected structure for a given hint value. This approach keeps the `hints` directive simple (`Map`) while allowing executors to structure hint values however they want (as long as it's a string). +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 comma-separated string of `=` entries. The AWS Batch executor would parse this string into a map and supply it to each job request using `ConsumableResourceProperties`. +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 consumableResources: ['my-dragen-license': 1] hints 'scheduling.priority': 10 hints 'scheduling.provisioningModel': 'spot' ``` @@ -100,7 +100,7 @@ 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' +hints 'awsbatch/consumableResources': ['my-dragen-license': 1] hints 'seqera/scheduling.provisioningModel': 'spot' hints 'k8s/nodeSelector': 'gpu=true' ``` @@ -126,7 +126,7 @@ process { // specific hint replaces generic hint withLabel: 'dragen' { - hints = [consumableResources: 'my-dragen-license=1'] + hints = [consumableResources: ['my-dragen-license': 1]] } } ``` @@ -137,12 +137,12 @@ Within a process definition, the `hints` directive uses *accumulation semantics* process runDragen { // multiple separate hints hints provisioningModel: 'spot' - hints consumableResources: 'my-dragen-license=1,other-license=2' + hints consumableResources: ['my-dragen-license': 1, 'other-license': 2] // equivalent to... hints ( provisioningModel: 'spot', - consumableResources: 'my-dragen-license=1,other-license=2' + consumableResources: ['my-dragen-license': 1, 'other-license': 2] ) // ... @@ -158,7 +158,7 @@ process { hints = [provisioningModel: 'spot'] withLabel: 'dragen' { - hints = [provisioningModel: 'spot', consumableResources: 'my-dragen-license=1'] + hints = [provisioningModel: 'spot', consumableResources: ['my-dragen-license': 1]] } } ``` @@ -169,11 +169,11 @@ While this approach may lead to duplication, it gives users and developers more The following hints should be supported initially: -| Hint name | Executors | Use case | -|--|--|--| -| `consumableResources` | AWS Batch | License-aware scheduling ([#5917](https://github.com/nextflow-io/nextflow/issues/5917)) | -| `scheduling.priority` | AWS Batch | Job scheduling priority ([#6998](https://github.com/nextflow-io/nextflow/issues/6998)) | -| `scheduling.provisioningModel` | Google Batch | Spot VM scheduling ([#3530](https://github.com/nextflow-io/nextflow/issues/3530)) | +| 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 diff --git a/docs/executor.md b/docs/executor.md index eee980f9f8..ef9a1606da 100644 --- a/docs/executor.md +++ b/docs/executor.md @@ -38,7 +38,7 @@ 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' + hints consumableResources: ['my-license-a': 1, 'my-license-b': 2] ``` See {ref}`aws-batch` for more information. @@ -463,7 +463,7 @@ The following {ref}`hints ` are supported: - `machineRequirement.maxSpotAttempts` - `machineRequirement.provisioning` -Each hint overrides the corresponding field of the `seqera.executor.machineRequirement` config scope on a per-process basis. Values must be strings; keys may be used as-is or with the `seqera/` prefix to restrict them to this executor. +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: diff --git a/docs/reference/process.md b/docs/reference/process.md index f66e5736fc..4a5fa08113 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -844,13 +844,13 @@ The above example produces: ### 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 must be strings. +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 -- 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' + hints consumableResources: ['my-license': 1] script: """ @@ -862,7 +862,7 @@ process hello { To restrict a hint to a single executor, prefix the key with the executor name: ```nextflow - hints 'awsbatch/consumableResources': 'my-license=1' + 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. diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy index f77bd86dc9..a3e72d6a7b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/HintDefs.groovy @@ -24,7 +24,8 @@ import groovy.transform.CompileStatic * 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}. + * only enforces that the map conforms to {@code Map} with + * raw data type values. * * @author Paolo Di Tommaso */ @@ -40,17 +41,18 @@ class HintDefs { *
  • keys must be non-empty
  • *
  • keys may contain at most one {@code /} separating the optional * executor namespace from the hint name
  • - *
  • values must be {@code String} or {@code null}
  • + *
  • 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) { + static void validateHints(Map hints) { if( !hints ) return - for( Map.Entry entry : hints.entrySet() ) { + for( final entry : hints.entrySet() ) { final key = entry.key final value = entry.value @@ -60,9 +62,18 @@ class HintDefs { if( key.count('/') > 1 ) throw new IllegalArgumentException("Invalid hint key '${key}': expected 'name' or 'executor/name'") - if( value != null && !(value instanceof CharSequence) ) - throw new IllegalArgumentException("Invalid hint value for key '${key}': expected String, got ${value.getClass().getName()}") + 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 f184e93f85..1f79ac1c3b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskConfig.groovy @@ -529,8 +529,8 @@ class TaskConfig extends LazyMap implements Cloneable { return CmdLineOptionMap.emptyOption() } - Map getHints() { - return get('hints') as Map ?: Collections.emptyMap() + Map getHints() { + return get('hints') as Map ?: Collections.emptyMap() } Map getResourceLabels() { diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy index 624783914b..a56feccced 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy @@ -173,8 +173,8 @@ class ProcessConfig implements Map, Cloneable { HashMode.of(configProperties.cache) ?: HashMode.DEFAULT() } - Map getHints() { - (configProperties.get('hints') ?: Collections.emptyMap()) as Map + Map getHints() { + (configProperties.get('hints') ?: Collections.emptyMap()) as Map } Map getResourceLabels() { 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 5d26298875..6c353b8374 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy @@ -234,7 +234,7 @@ class ProcessBuilder { * * @param map */ - void hints(Map map) { + void hints(Map map) { if( !map ) return HintDefs.validateHints(map) diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy index 8953890d9a..733d041278 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/HintDefsTest.groovy @@ -28,8 +28,11 @@ class HintDefsTest extends Specification { def 'should accept valid hints'() { when: HintDefs.validateHints([ - consumableResources: 'my-license=1', - 'awsbatch/consumableResources': 'a=1,b=2', + 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: @@ -37,32 +40,18 @@ class HintDefsTest extends Specification { } def 'should accept null and empty maps'() { - expect: - HintDefs.validateHints(null) == null - HintDefs.validateHints([:]) == null - } - - def 'should accept null hint value'() { when: - HintDefs.validateHints([consumableResources: null]) + HintDefs.validateHints(null) + HintDefs.validateHints([:]) then: noExceptionThrown() } - def 'should reject non-string value'() { - when: - HintDefs.validateHints([consumableResources: 42]) - then: - def e = thrown(IllegalArgumentException) - e.message.contains("Invalid hint value") - e.message.contains("consumableResources") - } - - def 'should reject list value'() { + def 'should accept null hint value'() { when: - HintDefs.validateHints([consumableResources: ['a', 'b']]) + HintDefs.validateHints([consumableResources: null]) then: - thrown(IllegalArgumentException) + noExceptionThrown() } def 'should reject closure value'() { diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy index 9727033c9b..afd01afb64 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy @@ -629,15 +629,15 @@ class TaskConfigTest extends Specification { when: def process = new ProcessConfig(script) def dsl = new ProcessBuilder(process) - dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license=1' ) + dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1] ) then: - process.get('hints') == ['seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license=1'] + 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'] + config.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1]] } def 'should return empty map when no hints set'() { @@ -654,15 +654,15 @@ class TaskConfigTest extends Specification { 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' ) + dsl.hints( 'seqera/machineRequirement.arch': 'arm64', consumableResources: ['my-license': 1] ) then: - process.getHints() == ['seqera/machineRequirement.arch': 'arm64', consumableResources: 'my-license'] + 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']) + config.put('hints', ['scheduling.priority': 5]) then: - config.getHints() == ['scheduling.priority': '5'] + config.getHints() == ['scheduling.priority': 5] } def 'should report error on negative cpus' () { 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 83fe47545f..9767bc2f96 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy @@ -194,7 +194,7 @@ class ProcessBuilderTest extends Specification { config.getHints() == ['seqera/machineRequirement.arch': 'x86_64', 'seqera/machineRequirement.provisioning': 'spot', 'seqera/machineRequirement.maxSpotAttempts': '3'] } - def 'should reject non-string hint values' () { + def 'should reject closure hint values' () { given: def builder = createBuilder() @@ -202,11 +202,17 @@ class ProcessBuilderTest extends Specification { 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: - thrown(IllegalArgumentException) + noExceptionThrown() } def 'should check a valid label' () { 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 ed5f7e407d..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 @@ -198,7 +198,7 @@ void disk( [Read more](https://nextflow.io/docs/latest/reference/process.html#hints) """) - void hints(Map value); + 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 9e170ce240..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 @@ -230,7 +230,7 @@ public interface TaskConfig { [Read more](https://nextflow.io/docs/latest/reference/process.html#hints) """) - Map getHints(); + Map getHints(); @Constant("label") @Description(""" 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 2b4b26907e..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 @@ -692,33 +692,27 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler hints) { + protected ConsumableResourceProperties getConsumableResources(Map hints) { if( !hints ) return null warnUnknownHints(hints) final raw = hints.get(HINT_PREFIX + 'consumableResources') ?: hints.get('consumableResources') - if( !raw?.trim() ) + 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( final token : raw.tokenize(',') ) { - final entry = token.trim() - if( !entry ) - continue - final index = entry.indexOf('=') - if( index <= 0 || index == entry.length()-1 ) - throw new IllegalArgumentException("Invalid 'consumableResources' hint entry '${entry}' -- expected 'name=quantity'") - final resourceName = entry.substring(0, index).trim() - final qtyStr = entry.substring(index+1).trim() - final long resourceQuantity - try { - resourceQuantity = qtyStr.toLong() - } - catch( NumberFormatException e ) { - throw new IllegalArgumentException("Invalid 'consumableResources' hint entry '${entry}' -- quantity must be an integer") - } + 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(resourceQuantity) + .quantity(((Number)value).longValue()) .build() ) } if( !resourceList ) @@ -729,7 +723,7 @@ class AwsBatchTaskHandler extends TaskHandler implements BatchHandler hints) { + protected void warnUnknownHints(Map hints) { for( final key : hints.keySet() ) { if( !key?.startsWith(HINT_PREFIX) ) continue 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 3ac28084fc..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 @@ -605,7 +605,7 @@ class AwsBatchTaskHandlerTest extends Specification { 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', + 'awsbatch/consumableResources': ['license-a': 1, 'license-b': 2], foo: 'bar' ) def taskConfig = process.createTaskConfig() @@ -641,8 +641,8 @@ class AwsBatchTaskHandlerTest extends Specification { when: def result = handler.getConsumableResources([ - consumableResources: 'legacy=9', - 'awsbatch/consumableResources': 'license-a=1,license-b=2', + consumableResources: ['legacy': 9], + 'awsbatch/consumableResources': ['license-a': 1, 'license-b': 2], ]) then: result.consumableResourceList().size() == 2 @@ -660,50 +660,25 @@ class AwsBatchTaskHandlerTest extends Specification { handler.getConsumableResources(null) == null handler.getConsumableResources([:]) == null handler.getConsumableResources([foo: 'bar']) == null - handler.getConsumableResources([consumableResources: '']) == null - handler.getConsumableResources([consumableResources: ' ']) == null + handler.getConsumableResources([consumableResources: null]) == null + handler.getConsumableResources([consumableResources: [:]]) == null } - def 'should tolerate whitespace in consumableResources entries' () { + def 'should fail with a clear error on invalid consumableResources value' () { given: def handler = new AwsBatchTaskHandler() - when: - def result = handler.getConsumableResources([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 fail with a clear error on invalid consumableResources entry' () { - given: - def handler = new AwsBatchTaskHandler() - - when: - handler.getConsumableResources([consumableResources: 'bad']) + when: 'value is a string instead of a map' + handler.getConsumableResources([consumableResources: 'bad-string']) then: def e = thrown(IllegalArgumentException) - e.message.contains("'bad'") - e.message.contains("name=quantity") + e.message.contains("expected a map") - when: - handler.getConsumableResources([consumableResources: 'license-a=not-a-number']) + 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 an integer") - - when: - handler.getConsumableResources([consumableResources: '=1']) - then: - thrown(IllegalArgumentException) - - when: - handler.getConsumableResources([consumableResources: 'license-a=']) - then: - 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/util/HintHelper.groovy b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy index d8a48365ad..85fd29b202 100644 --- a/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy +++ b/plugins/nf-seqera/src/main/io/seqera/util/HintHelper.groovy @@ -64,13 +64,13 @@ class HintHelper { * @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) { + 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 unprefixed = new LinkedHashMap() + final prefixed = new LinkedHashMap() + for( Map.Entry entry : hints.entrySet() ) { final key = entry.key if( !key ) continue @@ -99,7 +99,7 @@ class HintHelper { * @param hints the full hints map from task config * @return a new {@link MachineRequirementOpts} with hints overlaid */ - static MachineRequirementOpts overlayHints(MachineRequirementOpts baseOpts, Map hints) { + static MachineRequirementOpts overlayHints(MachineRequirementOpts baseOpts, Map hints) { final seqeraHints = extractSeqeraHints(hints) if( !seqeraHints ) return baseOpts @@ -111,24 +111,14 @@ class HintHelper { merged.put(field.name, value) } - for( Map.Entry entry : seqeraHints.entrySet() ) { + 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 } - // special-case types that need parsing before the MachineRequirementOpts constructor can coerce them - switch( fieldName ) { - case 'machineTypes': - merged.put(fieldName, value.split(',').collect { it.trim() }) - break - case 'diskEncrypted': - merged.put(fieldName, Boolean.parseBoolean(value)) - break - default: - merged.put(fieldName, value) - } + 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 26d8040079..7aec5840a1 100644 --- a/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/executor/SeqeraTaskHandlerTest.groovy @@ -895,7 +895,7 @@ class SeqeraTaskHandlerTest extends Specification { } private SeqeraTaskHandler createSubmitHandler(Map args) { - final hints = args.hints as Map ?: [:] + final hints = args.hints as Map ?: [:] final baseMachineReq = args.baseMachineReq as MachineRequirementOpts final Closure onSubmit = args.onSubmit as Closure ?: {} diff --git a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy index 291d296624..26efe36798 100644 --- a/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy +++ b/plugins/nf-seqera/src/test/io/seqera/util/HintHelperTest.groovy @@ -62,17 +62,17 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.maxSpotAttempts': '3']) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.maxSpotAttempts': 3]) then: result.maxSpotAttempts == 3 } - def 'should overlay machineTypes from comma-separated string'() { + def 'should overlay machineTypes as list'() { given: def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.machineTypes': 'm5,m5a,m6i']) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.machineTypes': ['m5', 'm5a', 'm6i']]) then: result.machineTypes == ['m5', 'm5a', 'm6i'] } @@ -92,7 +92,7 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskThroughputMiBps': '500']) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskThroughputMiBps': 500]) then: result.diskThroughputMiBps == 500 } @@ -102,17 +102,17 @@ class HintHelperTest extends Specification { def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskIops': '10000']) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskIops': 10000]) then: result.diskIops == 10000 } - def 'should overlay diskEncrypted hint from string'() { + def 'should overlay diskEncrypted hint as boolean'() { given: def base = new MachineRequirementOpts([:]) when: - def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskEncrypted': 'true']) + def result = HintHelper.overlayHints(base, ['seqera/machineRequirement.diskEncrypted': true]) then: result.diskEncrypted == true } @@ -164,7 +164,7 @@ class HintHelperTest extends Specification { when: def result = HintHelper.overlayHints(base, [ 'seqera/machineRequirement.provisioning': 'spotFirst', - 'seqera/machineRequirement.maxSpotAttempts': '3', + 'seqera/machineRequirement.maxSpotAttempts': 3, 'seqera/machineRequirement.diskType': 'ebs/gp3' ]) then: From df1d6cd3fedef1bcf90969f7955973fd68612236 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sat, 25 Apr 2026 12:00:41 +0200 Subject: [PATCH 13/16] Update docs/reference/config.md [ci skip] Co-authored-by: Chris Hakkaart Signed-off-by: Paolo Di Tommaso --- docs/reference/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/config.md b/docs/reference/config.md index 793c226e91..c4a3fd56d8 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -1440,7 +1440,7 @@ The following settings are available: : 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, e.g. `['m5', 'c5', 'r5']`. +: 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'`. From 2551214046f83b517c692f56a93597ef8253eff3 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sat, 25 Apr 2026 12:00:56 +0200 Subject: [PATCH 14/16] Update docs/reference/process.md [ci skip] Co-authored-by: Chris Hakkaart Signed-off-by: Paolo Di Tommaso --- docs/reference/process.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/process.md b/docs/reference/process.md index 4a5fa08113..d2f645e3c2 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -844,7 +844,7 @@ The above example produces: ### 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 -- numbers, strings, booleans, lists, and maps. +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: From bb3ddaa8e6fe69bebe5539547713e2cd03a959db Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sat, 25 Apr 2026 12:01:09 +0200 Subject: [PATCH 15/16] Update docs/reference/process.md [ci skip] Co-authored-by: Chris Hakkaart Signed-off-by: Paolo Di Tommaso --- docs/reference/process.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/process.md b/docs/reference/process.md index d2f645e3c2..fb93d2e7d1 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -846,7 +846,7 @@ The above example produces: 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: +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 { From f47191494e27740f30fc066529e4660f1cd4bfd1 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sat, 25 Apr 2026 12:01:22 +0200 Subject: [PATCH 16/16] Update docs/reference/process.md [ci skip] Co-authored-by: Chris Hakkaart Signed-off-by: Paolo Di Tommaso --- docs/reference/process.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/process.md b/docs/reference/process.md index fb93d2e7d1..4032aa1e0d 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -867,7 +867,7 @@ To restrict a hint to a single executor, prefix the key with the executor name: 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. +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.