Skip to content

Report the task directives read while the command is rendered - #7506

Merged
pditommaso merged 3 commits into
masterfrom
directive-refs-observer
Aug 20, 2026
Merged

Report the task directives read while the command is rendered#7506
pditommaso merged 3 commits into
masterfrom
directive-refs-observer

Conversation

@bentsherman

Copy link
Copy Markdown
Member

Rendering the task command reads the directives it interpolates off the task config; record those reads so that an executor adjusting the resources at schedule time can tell whether the rendered command carries a value it is about to change.

Being observed rather than inferred, this covers the script, a shell block, a template file and a dynamic directive value the command interpolates, in both parsers, without any of them being known to the check. A directive resolved after the command has been rendered is not covered.

nf-seqera submits the affected tasks with prediction model none.

Rendering the task command reads the directives it interpolates off the task
config; record those reads so that an executor adjusting the resources at
schedule time can tell whether the rendered command carries a value it is about
to change.

Being observed rather than inferred, this covers the script, a `shell` block, a
`template` file and a dynamic directive value the command interpolates, in both
parsers, without any of them being known to the check. A directive resolved
after the command has been rendered is not covered.

nf-seqera submits the affected tasks with prediction model `none`.

Signed-off-by: Ben Sherman <bentshermann@gmail.com>
@bentsherman
bentsherman requested a review from pditommaso August 19, 2026 19:15
@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for nextflow-docs canceled.

Name Link
🔨 Latest commit e08759c
🔍 Latest deploy log https://app.netlify.com/projects/nextflow-docs/deploys/6a86b4090792ac00081d5738

@pditommaso

pditommaso commented Aug 19, 2026

Copy link
Copy Markdown
Member

Reviewed b148cfbaf..f7bb8a80c with test runs and a few throwaway probes. I like this approach a lot — observing the reads is both smaller and more precise than collecting references statically, and it picks up the config ext.args idiom for free.

Two subtle details are already right, and both would have been easy to miss: recording happens before the cache lookup (TaskConfig.groovy:168), and ext isn't masked by the cache since TaskConfig#cache holds the LazyMap rather than the resolved value. I confirmed both with probes, along with no false positives on memory (a plain echo hello script records nothing). I also suspected a thread-visibility race between the rendering and submitter threads, but it's fine — TaskPollingMonitor#schedule hands off through a LinkedBlockingQueue, which gives the happens-before edge. All existing tests pass.

One thing I'd fix before merge — clone() drops the reads but keeps the rendered script.

TaskConfig#clone() calls newCache(), which now also resets reads, while TaskRun#clone() is a shallow copy that carries script over. So a copied task has the memory-baked command and an empty set:

PROBE (makeCopy): script == 'java -Xmx8g', isDirectiveReferenced('memory') == false

This reaches TaskProcessor.groovy:1064-1069, where the ProcessRetryableException / CloudSpotTerminationException path calls makeCopy() and then submits without re-resolving. The ordinary RETRY path at :1184 does re-resolve, so it's unaffected. Nothing in nf-seqera raises either exception today, so it's latent rather than live — but it fails silently in exactly the OOM direction the feature exists to prevent. The underlying cause is that the value cache and the access log have different lifetimes, so moving the reset out of newCache() and copying the set in clone() should do it.

A couple of smaller things:

  • The "single funnel" comment at TaskConfig.groovy:169 isn't quite accurate — eval() reaches LazyMap#getValue via eval0 and bypasses recording (it's what TaskHasher:207 uses), and getRawValue reads target directly. Nothing on the rendering path uses them today, so no live bug, but it might be worth naming the exceptions so a future read routed through eval() inside the window doesn't silently go unobserved.
  • The config-file case isn't covered by a test — every case in TaskDirectiveReadsTest builds new TaskConfig(...) directly, so no config parser is exercised. The mechanism is parser-agnostic by construction, so the risk is low, but since the withName: FOO { ext.args = {...} } idiom is the main advantage here it would be nice to pin it.

Minor, for later: a cached top-level dynamic directive could mask a read (containerOptions = { "...${task.memory}..." } resolved before resolve() returns from cache) — I confirmed the mechanism but found no live trigger, so perhaps just a comment at the cache; task-array tasks never go through resolve() so they always answer false (TaskArrayCollector.groovy:135), which could join the beforeScript caveat in the javadoc; and return config?.isDirectiveRead(...) from a boolean method is fine under dynamic Groovy but would become an unboxing NPE under @CompileStatic.

On naming, one suggestion: record tends to read as a noun in this codebase (TraceRecord, ProgressRecord, RecordMap, plus the Java 17 keyword in nf-lang), and read is a little ambiguous in a config class. access sidesteps both, and it's already the vocabulary here — TaskRun.groovy:991 describes getGlobalVars as "the set of task variables accessed in global script context":

private transient Set<String> accessedDirectives
private transient boolean trackingAccess

void trackDirectiveAccess(boolean value)
boolean isDirectiveAccessed(String directive)

Two optional extras: bracketing the window as config.trackDirectiveAccess { ... } would remove the try/finally and make a leaked flag impossible; and since the query really means "was accessed during the most recent resolve()" — empty before resolve, after clone, and for exec: tasks — a javadoc line saying it returns false when resolve() hasn't run would help the next caller. I'd leave the public TaskRun#isDirectiveReferenced name alone for now, since it's shared with the other PRs.


Update. I've pushed 30f08bf to this branch with the clone() fix, the access rename, the extra comments, and the new test cases — including the config-file ext.args idiom driven through both ConfigParserV1 and ConfigParserV2. Your local branch will be behind, sorry for the interruption.

Two of the suggestions above didn't survive contact with the tests, so I left them out:

Also worth noting the ?. in resolve() is load-bearing, which I'd questioned above: stubRun && config.getStubBlock() short-circuits, so removing it NPEs a unit test that resolves a body with no config. I've added a comment saying so.

- carry the accessed directives over to a task copy: the copy keeps the command
  that was rendered from them, therefore it depends on the same directives.
  TaskConfig#newCache no longer resets them, since the value cache belongs to a
  context while the access log belongs to a rendered command
- rename the tracking to the `access` vocabulary: `record` reads as a noun in
  this codebase (TraceRecord, ProgressRecord, RecordMap, and the Java keyword)
  and `read` is ambiguous in a config class
- note the paths that bypass TaskConfig#get, and the dynamic top-level directive
  whose nested access the value cache can hide
- document that the check answers false before the command has been rendered,
  for a native `exec` task and for a task array
- cover the absence of false positives, the task copy, and the config-file
  `ext.args` idiom through both config parsers

Assisted-by: Claude Opus 5 (Claude Code)
Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
State the three cases as a list, so the nested ternary reads as precedence
rather than as a check: an explicit hint, then the automatic `task.memory`
check, then null to inherit the run-level model.

Assisted-by: Claude Opus 5 (Claude Code)
Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com>
@pditommaso
pditommaso merged commit 1b4e757 into master Aug 20, 2026
11 checks passed
@pditommaso
pditommaso deleted the directive-refs-observer branch August 20, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants