Detect task directive references in dynamic config values - #7507
Closed
pditommaso wants to merge 1 commit into
Closed
Detect task directive references in dynamic config values#7507pditommaso wants to merge 1 commit into
pditommaso wants to merge 1 commit into
Conversation
A dynamic directive value defined in the config file, e.g.
process.ext.args = { "-Xmx${task.memory.toGiga()}g" }
is a closure resolved by LazyMap, never by the template engine, so the script
variable references collected by #7505 only ever show `task.ext.args` -- the
`task.memory` dependency is invisible. A closure carries no source text at
runtime, so the names are collected from the config AST at compile time and
attached to the value by DirectiveRefsClosure.
The wrapping is skipped when `renderClosureAsString` is set: `nextflow config`
and `kuberun` replace every closure with its source text, so the two paths are
mutually exclusive and the renderer always sees the plain closure.
TaskConfig#isDirectiveReferenced takes the source directives to inspect, so a
caller only counts a reference made by a directive it actually consumes.
TaskRun passes the directives rendered into the task command -- `ext`,
`beforeScript`, `afterScript`, `containerOptions` -- which keeps a common
`clusterOptions = { "-l h_vmem=${task.memory}" }` in an institutional profile
from reporting every process of the pipeline as memory-dependent.
Limitation: NXF_SYNTAX_PARSER=v1 selects ConfigParserV1, which has no
ConfigToGroovyVisitor, so config references are not collected there.
Assisted-by: Claude Opus 5 (Claude Code)
Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
This was referenced Aug 19, 2026
Member
Author
|
Closing in favour of #7506 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
#7505 makes
TaskRun#isDirectiveReferenced(String)work off the script variable references, so nf-seqera can disable resource prediction for a process whose script bakes intask.memory. It does not see a reference made by a dynamic directive value defined in the config file:process { withName: FOO { ext.args = { "-Xmx${task.memory.toGiga()}g" } } }The script only shows
task.ext.args; the closure is resolved byLazyMap, never by the template engine, and a config closure carries no source text at runtime (ClosureToStringVisitoronly runs fornextflow config/kuberun;TaskClosureis only applied towhen/stub). So the reference has to be collected at compile time.This is the common shape in nf-core pipelines, so without it the safety net of #7505 misses most real occurrences.
Approach
Ported from the config half of #7483, with two refinements.
DirectiveRefCollector(nf-lang) — walks an AST expression and reports thetask.<name>property reads whose name is a process directive. The name set is derived fromProcessDsl.DirectiveDslrather than hard-coded, sotask.attempt/task.exitStatusare correctly ignored whiletask.memory/task.timeare collected, and a new directive needs no second place to update.ConfigToGroovyVisitor#transformDirectiveRefs— wraps a dynamic directive value that references a directive into aDirectiveRefsClosureconstructor call carrying the names (recursing into list/map literals, mirroringClosureToStringVisitor#replaceClosures). Only closures are wrapped:taskis not defined in the config scope, so a plain interpolated string referencing it is already a parse error, which makes a closure the only shape that can carry a reference.DirectiveRefsClosure(nextflow) — delegates everyClosuremethod to the closure it wraps, so the value keeps behaving exactly as written whenLazyMap#resolveImplclones and calls it. Follows the pattern ofTaskClosure.TaskConfig#isDirectiveReferenced(directive, fromDirectives = null)— inspects the raw config entries (nothing is resolved as a side effect of the check) and reports whether any value declares the reference.The process-definition side is untouched: it stays exactly as #7505 implements it, via
valRefs.TaskRun#isDirectiveReferencedbecomes the union of the two.Not ported from #7483:
BodyDef.directiveRefsand its 5th constructor argument, theProcessToGroovyVisitorV1/V2changes, its ownProcessDirectiveRefsTest(#7505's is kept), and theClosureToStringVisitorchange (see refinement 1).Refinement 1 — guard on
!renderClosureAsStringinstead of patchingClosureToStringVisitor#7483 wrapped closures unconditionally, which left
nextflow configandkuberunrendering aDirectiveRefsClosureconstructor call where a source-text placeholder belongs — corrupting the output. It fixed that by teachingClosureToStringVisitorto unwrap the constructor call again.The rendering path and the running path are mutually exclusive:
nextflow config/kuberunnever need directive refs, and a run never renders closures as strings. SoConfigCompilernow threads itsrenderClosureAsStringflag intoConfigToGroovyVisitor, which skips the wrapping entirely when it is set. The renderer therefore always sees the plain closure the user wrote, andClosureToStringVisitorneeds no knowledge of this feature at all.Covered by
should render a dynamic directive as a string placeholder, which asserts both theConfigClosurePlaceholderand the original source text surviving intoConfigHelper.toCanonicalString.Refinement 2 — scope the check by source directive
#7483 flattened every reference into one union, which over-triggers. The nf-core institutional profiles routinely carry:
process { clusterOptions = { "-l h_vmem=${task.memory.toString().replaceAll(/[\sB]/, '')}" } }clusterOptionsis read only by the grid executors (AbstractGridExecutor,Lsf/Pbs/PbsPro,TaskArrayCollector) and is put in the job header, not in the command.plugins/nf-seqera/src/mainnever touchesclusterOptions,queueorpenv. Under the flattened union, that one line at the top of theprocessscope disables resource prediction for every process in the pipeline, on the strength of a directive the executor ignores — which defeats the feature.So
isDirectiveReferencedtakes an optional collection of source directives to inspect;nullkeeps the broad behaviour. No keying on the closure is needed — the config entry key already is the source directive (extforext.args = {...}), so the filter is on the entry key.TaskRunpassesCOMMAND_DIRECTIVES = ['ext', 'beforeScript', 'afterScript', 'containerOptions']: the directives whose value is user-authored text baked verbatim into the rendered command or the container run spec, verified againstTaskBean/BashWrapperBuilder:ext${task.ext.args}beforeScript/afterScript.command.run(BashWrapperBuilder:405,543)containerOptionsbuilder.addRunOptions(containerOptions)(BashWrapperBuilder:763)clusterOptions,queue,penvThe other directives
TaskBeanfeeds to the wrapper (module,shell,scratch,stageInMode, …) take enumerated or structured values that cannot meaningfully carry a resource figure, and are left out.Note
containerOptionsis inert on the Seqera path specifically —SeqeraExecutor#isContainerNative()istrue, sorunWithContaineris false and nf-seqera never forwards it — but it is a genuine command dependency for the container-managed executors.Where the set lives
In core (
TaskRun), not nf-seqera. The set answers "which config directives are rendered into the task command", which is a property ofTaskBean/BashWrapperBuilder— the same knowledge that would go stale if a directive were added to the wrapper. nf-seqera's question is the executor-specific one ("does this command depend ontask.memory"), and it already asks exactly that throughTaskRun#isDirectiveReferenced(String); nf-seqera needs no change in this PR. Putting the set in the plugin would duplicate it into every executor that later wants the same check.Known limitation
NXF_SYNTAX_PARSER=v1selectsConfigParserV1(ConfigParserFactory:37), which has noConfigToGroovyVisitor— so this is v2-config-parser only and degrades silently to the #7505 behaviour (script references only). Same limitation as #7483. Documented in the nf-seqera README.Test evidence
New
ConfigDirectiveRefsTest(13 cases): detection inext/ plain directives / nested list+map values;task.attemptandtask.exitStatuscorrectly ignored; the wrapped value still resolving normally throughLazyMap(beforeScript→echo -Xmx8g); source-directive scoping in both directions; thewithName:selector skip for a matching and a non-matching process; thenextflow configplaceholder rendering; and theTaskRununion.The selector skip is a real bug found in review of #7483:
ProcessConfigBuilder#applyConfigDefaultscopies every key of theprocessscope into each process config, including thewithName:/withLabel:blocks that were not applied — so without the skip a single selector makes every process report a reference. A selector that does match is already merged into the top-level directives, so true positives are unaffected.TaskHasherTestandTaskRunTestare included to show no hash/resume regression: the collected names never enter the hash — they ride on the config value, andTaskRun#getGlobalVarsskipstask.*as task-local anyway.🤖 Generated with Claude Code