Skip to content

NIFI-15862: Moved Processors to using Virtual Threads with a Semaphor… - #11164

Merged
exceptionfactory merged 11 commits into
apache:mainfrom
markap14:virtual-threads
Sep 18, 2026
Merged

exceptionfactory merged 11 commits into
apache:mainfrom
markap14:virtual-threads

Conversation

@markap14

Copy link
Copy Markdown
Contributor

…e bounding how many tasks can be run at once

Summary

NIFI-00000

Tracking

Please complete the following tracking steps prior to pull request creation.

Issue Tracking

Pull Request Tracking

  • Pull Request title starts with Apache NiFi Jira issue number, such as NIFI-00000
  • Pull Request commit message starts with Apache NiFi Jira issue number, as such NIFI-00000
  • Pull request contains commits signed with a registered key indicating Verified status

Pull Request Formatting

  • Pull Request based on current revision of the main branch
  • Pull Request refers to a feature branch with one commit containing changes

Verification

Please indicate the verification steps performed prior to pull request creation.

Build

  • Build completed using ./mvnw clean install -P contrib-check
    • JDK 21
    • JDK 25

Licensing

  • New dependencies are compatible with the Apache License 2.0 according to the License Policy
  • New dependencies are documented in applicable LICENSE and NOTICE files

Documentation

  • Documentation formatting appears as expected in rendered files

@markap14
markap14 force-pushed the virtual-threads branch 2 times, most recently from 64459cf to 8f34b35 Compare April 23, 2026 15:09
if (tempDirectory != null) {
try {
Files.deleteIfExists(tempDirectory);
} catch (final IOException ignored) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we at least log this at debug so that a recurring failure to delete nifi-thread-dump-* directories is discoverable?

@markap14 markap14 May 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that's reasonable.

}

private static String buildThreadName(final Connectable connectable, final int taskIndex) {
return connectable.getName() + "[type=" + connectable.getComponentType() + ", id=" + connectable.getIdentifier()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the user renames the component and/or its parent group? would that be properly reflected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thread name would get generated whenever the Processor is started. It would not change. The only ways to change it would be to track all Virtual Thread objects so that setName could update them, or to constantly update it to whatever the current name is. Both introduce a decent bit of complexity for what I'd argue is fairly minimal gain. Especially since I don't think we even allow changing the name of the processor while it's running from the UI. I think the REST tier does allow it, but the UI would not, though I may be mistaken.

@markap14

Copy link
Copy Markdown
Contributor Author

[claude-opus-4.7] @pvillard31 thanks for the latest round of feedback. The new commit (2c0e041) addresses your remaining comments and rebases onto current main:

  • ThreadDumpTask now logs at debug rather than silently swallowing the cleanup IOException, so a recurring failure to delete nifi-thread-dump-* directories is discoverable.
  • On the thread-name-after-rename question: after some prototyping we decided not to refresh the virtual thread's name inside the scheduling loop. Resetting the name on every iteration adds an allocation-heavy buildThreadName call to a hot path that may run at zero scheduling period, and cache-and-compare workarounds add complexity for what is purely a diagnostic nicety. The name resolved at scheduling time (component name + type + id + parent-group name + task index) still contains the immutable component id, so threads remain identifiable in dumps. If the user wants the new name reflected, stopping and restarting the component is sufficient.

The earlier review-feedback commit (0abffe2) is also still on the branch and covers the prior round of comments: atomic schedule rollback (#3), thread tracking + shutdown() interrupt with FlowController kill-path hook (#5), setMaxThreadCount synchronization (#2), and the response on runOnce polling (#1). Force push was required to incorporate the main rebase.

@markap14
markap14 requested a review from pvillard31 May 12, 2026 20:04
@markap14

Copy link
Copy Markdown
Contributor Author

[claude-opus-4.7] @markap14 important: I had to revert my last push.

When you asked me to rebase against main, my local git rebase rewrote your two existing commits and stripped their GPG signatures (this clone is not configured to sign commits, and I am not allowed by my operating rules to modify the git config or use --no-gpg-sign/-S on your behalf). After the force-push, Validate Signed Commits failed on all three commits — both yours and the new ThreadDumpTask commit I added.

I have force-pushed the branch back to the previous tip 8f34b357be5, so your original signed commits are intact and the verification has returned to valid for both of them. The PR is back to where it was before I touched it today.

What still needs to happen, in your hands:

  1. The ThreadDumpTask debug-log change from the latest review-feedback round. The one-line patch is:
-                } catch (final IOException ignored) {
+                } catch (final IOException e) {
+                    logger.debug(\"Failed to delete temporary thread-dump directory {}\", tempDirectory, e);
                 }

(plus the org.slf4j.Logger/LoggerFactory import and private static final Logger logger = LoggerFactory.getLogger(ThreadDumpTask.class); field at the top of the class). I can post the full unified diff if useful.

  1. Whether to actually rebase against main. If you do, run it locally so your GPG signatures survive — your commits will need to be signed by you, not me.

Sorry for the churn. Watcher subagent is stopping until you let me know how you'd like to proceed.

@markap14
markap14 force-pushed the virtual-threads branch 3 times, most recently from c188bbc to 05608c8 Compare May 12, 2026 20:41
@markap14

Copy link
Copy Markdown
Contributor Author

I decided to go ahead and add something to nifi.properties that would allow us to enable/disable the virtual threads. This is a significant enough change that it's worth adding the ability to quickly disable if we find any issues.

@joewitt

joewitt commented May 13, 2026

Copy link
Copy Markdown
Contributor

Thanks @markap14 - To clarify it would remain for now default off. We'd give it time to bake/prove itself then later consider swapping to default on. Then perhaps later remove the old model entirely?

@markap14

Copy link
Copy Markdown
Contributor Author

[claude-opus-4.7] CI failure on macos-15 Java 25 (system-tests) — classified as pre-existing flake; failed shard rerun.

Failed check: macos-15 Java 25https://github.com/apache/nifi/actions/runs/25806807059/job/75811748202
Failing test: org.apache.nifi.tests.system.state.ClusterStateKeyDropIT.testCanDropSpecificStateKey

[ERROR] org.apache.nifi.tests.system.state.ClusterStateKeyDropIT.testCanDropSpecificStateKey -- Time elapsed: 1.662 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <{c=1, b=1, a=1}> but was: <{}>
    at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1188)
    at org.apache.nifi.tests.system.state.ClusterStateKeyDropIT.testCanDropSpecificStateKey(ClusterStateKeyDropIT.java:124)

Why this is unrelated to the PR:

  • ClusterStateKeyDropIT is known-flaky on macOS shards. NIFI-15763 (NIFI-15763 - Fix flaky test ClusterStateKeyDropIT.testCanDropSpecificStateKey #11070) previously added retry logic to this class for transient cluster errors. The same class flaked on main run https://github.com/apache/nifi/actions/runs/25457358299 (May 6) on macos-15 Java 21 for a sibling test method (testCannotDropStateKeyWithLocalAndClusterState), so the test class flakiness is independent of this PR.
  • This PR does not touch cluster state management, state providers, the coordinator, or anything ClusterStateKeyDropIT exercises. It only affects scheduling (virtual vs. timer-driven thread agents).
  • All other shards passed on this PR: ubuntu-24.04 Java 21, ubuntu-24.04 Java 25, and macos-15 Java 21. Only macos-15 Java 25 flaked.

Action taken: Reran only the failed macos-15 Java 25 shard via gh run rerun 25806807059 --failed --repo apache/nifi. Will continue to monitor.

@markap14

Copy link
Copy Markdown
Contributor Author

[claude-opus-4.7] CI rerun on macos-15 Java 25 failed again, but on a different test than before. Classifying as a separate likely flake and rerunning the shard once more.

Failed check: macos-15 Java 25https://github.com/apache/nifi/actions/runs/25806807059/job/75830543159
Failing test (this run): org.apache.nifi.tests.system.parameters.ClusteredParameterContextIT > ParameterContextIT.testProcessorRestartedAfterLongDependentServiceValidationPeriod

[ERROR] org.apache.nifi.tests.system.parameters.ClusteredParameterContextIT.testProcessorRestartedAfterLongDependentServiceValidationPeriod -- Time elapsed: 15.54 s <<< ERROR!
org.apache.nifi.toolkit.client.NiFiClientException: Error deleting Controller Service: Node localhost:5672 is unable to fulfill this request due to: StandardControllerServiceNode[service=StandardSleepService[...], name=StandardSleepService, active=false] cannot be deleted because it is not disabled
Caused by: jakarta.ws.rs.ClientErrorException: HTTP 409 Conflict

Why this is most likely unrelated to the PR:

  • The macos-15 Java 25 shard is independently flaky on this PR — the previous rerun was for a completely different test (ClusterStateKeyDropIT.testCanDropSpecificStateKey). Same shard failing on a different test each time is the signature of an environmentally flaky runner, not a deterministic regression from this PR.
  • All other shards pass: ubuntu-24.04 Java 21, ubuntu-24.04 Java 25, macos-15 Java 21, plus all the language/locale shards.
  • The test contains a pre-existing race independent of this PR: NiFiClientUtil.disableControllerService(...) (nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/NiFiClientUtil.java:1014-1021) only fires the disable request and returns immediately. It does not wait for the service to reach the DISABLED state. The very next line in the test (ParameterContextIT.java:614-615) calls deleteControllerService. The StandardSleepService under test has Validate Sleep Time = 6 secs set via the parameter context update, so if a validation cycle is in flight when disable is issued, the service stays in DISABLING long enough for the immediate delete to fail with HTTP 409 — exactly the observed error.
  • This PR does not change controller-service lifecycle, validation scheduling, or the disableControllerService helper. The scheduling changes only affect processor / reporting-task onTrigger invocations.

Action taken: Reran only the failed macos-15 Java 25 shard a second time via gh run rerun 25806807059 --failed --repo apache/nifi. If the same test fails again on the next run, I will stop reruns and investigate whether virtual-thread scheduling can prolong validation on macOS Java 25 specifically.

@markap14

Copy link
Copy Markdown
Contributor Author

@joewitt the intent here is to introduce it with default = on. Users could explicitly opt out if they wanted to. While the code here is not particularly complex and so I don't foresee specific issues, it is a pretty crucial part of the NiFi Engine. So introducing the option in nifi.properties as an 'escape hatch' just in case any issues do arise.

@github-actions

Copy link
Copy Markdown

Automated review is marking this PR as stale due to lack of updates in the past four months. This PR will be closed in 15 days if the stale label is not removed. This stale label and automated closure does not indicate a judgement of the PR, just lack of reviewer bandwidth and helps us keep the PR queue more manageable. If you would like this PR re-opened you can do so and a committer can remove the stale label. Or you can open a new PR. Try to help review other PRs to increase PR review bandwidth which in turn helps yours.

@github-actions github-actions Bot added the Stale label Sep 11, 2026
Co-authored-by: Cursor <cursoragent@cursor.com>

@exceptionfactory exceptionfactory left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@markap14 I hope to take a closer look at this soon. One configuration recommendation, instead of a boolean, what about a scheduling strategy with either STANDARD or TIMER_DRIVEN as the default, and VIRTUAL as the new value?

@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] CI failure on MacOS Zulu JDK 21 JP — classified as a pre-existing test race; the failed shard will be rerun after the parent workflow completes.

Failed check: MacOS Zulu JDK 21 JPhttps://github.com/apache/nifi/actions/runs/34631800449/job/103370270431
Failing test: org.apache.nifi.controller.scheduling.TestStandardProcessScheduler.validateNeverEnablingServiceCanStillBeDisabled

org.opentest4j.AssertionFailedError: expected: <0> but was: <1>
    at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
    at org.apache.nifi.controller.scheduling.TestStandardProcessScheduler.validateNeverEnablingServiceCanStillBeDisabled(TestStandardProcessScheduler.java:563)

This failure is unrelated to the PR. The test and controller-service lifecycle implementation are unchanged. The test calls asynchronous disableControllerService() and then immediately asserts that @OnDisabled has not run; on this runner, the callback completed quickly enough for the count to reach 1 first. The only branch diff in StandardProcessScheduler is comment wording in startConnectable().

Plan: rerun only this failed shard once run 34631800449 completes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] @exceptionfactory good suggestion. Commit c78511d replaces the boolean with nifi.scheduler.mode, backed by a ComponentSchedulingMode enum with STANDARD and VIRTUAL values. Invalid values now fail during startup instead of silently selecting an implementation.

I kept VIRTUAL as the default because the earlier rollout discussion settled on default-on with STANDARD as the escape hatch. The new push also starts a fresh MacOS Zulu JDK 21 JP run, so the pre-existing test race reported above will be retested on the current head instead of rerunning the superseded workflow.

@exceptionfactory exceptionfactory left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick response on the property configuration. I recommend calling the property nifi.scheduling.strategy and removing the new enum in favor of string values.

Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] @exceptionfactory addressed in f637504. The property is now nifi.scheduling.strategy, the enum has been removed, and STANDARD / VIRTUAL are handled as string values. VIRTUAL remains the default based on the earlier rollout decision. Focused scheduler and properties tests, Checkstyle, and PMD all pass.

@joewitt

joewitt commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Interrupt status kills the scheduling loop — share this

This is a real behavioral change versus the current pool.

Today, TimerDrivenSchedulingAgent submits a short-lived Runnable to FlowEngine. After onTrigger returns, that task is done. The next fire is a new executor task on a worker that has a clean interrupt flag. Processors that catch InterruptedException and do the textbook Thread.currentThread().interrupt() are therefore fine.

On this PR, each concurrent task is one virtual thread that never dies:

invoke() → waitForDelay() → acquirePermitWithPolling() → invoke() …

If onTrigger leaves the interrupt flag set:

waitForDelay() hits CountDownLatch.await and immediately throws InterruptedException.
The catch restores the interrupt (Thread.currentThread().interrupt()).
Next iteration, tryAcquire(...) throws immediately.
acquirePermitWithPolling returns false, and runSchedulingLoop returns.
The Processor stays RUNNING. The generation is still registered. Nothing reschedules that task. UI/thread count can look healthy. Throughput for that concurrent slot is just gone until stop/start.
I reproduced the primitive on JDK 21: a platform-pool worker’s next task is not interrupted; a persistent virtual thread that restored interrupt does fail the next timed wait.

This is not exotic. NiFi itself does this (e.g. DebugFlow, ExecuteProcess, ConsumeKafka backlog path, AMQP, Kinesis, HDFS, Listen processors). Any extension that “restores interrupt status and returns” is now a silent death sentence for that scheduling chain.

Ask Mark to either:

clear interrupt at a defined framework boundary after onTrigger unless shutdown/unschedule requested the interrupt, or
use a fresh virtual thread per invocation (old model, virtualized), or
treat interrupt as “this invocation was cancelled” and continue the loop if lifecycleState.isScheduled().
A regression test is easy: Processor that interrupt()s itself in onTrigger must fire again without a restart. Same for a Reporting Task.

@joewitt

joewitt commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

@markap14 left three chunky comments from various findings after wrestling through it with Cursor. Ignored the ones that didn't seem worthy and focused on what look like real findings. I'll keep an eye on this important change and will get to hands on live testing too once we're a little further.

markap14 and others added 2 commits September 11, 2026 15:53
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

markap14 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] @joewitt seriously, thank you for putting so much time into such a careful review. This was far beyond a drive-by "looks fine"—you found a real interrupt-shaped gremlin and gave the Java 21 risk a proper workout. That kind of effort keeps the scheduling goblins from escaping into production, and it is hugely appreciated.

The first two findings are addressed in the latest commits:

  • a87ca6d clears component interrupt status at the framework boundary after each Processor or Reporting Task invocation. The new regression tests failed on the prior code exactly as described—the second invocation never occurred—and now pass.
  • 300f0c6 adds AUTO as the default scheduling strategy. It resolves to STANDARD below Java 25 and VIRTUAL on Java 25 or newer; explicit STANDARD and VIRTUAL values still override it. The administration guide also warns about Java 21 monitor pinning.

Your permit-wait polling note was also accurate. We ultimately kept the simple bounded wait but increased it from 25 milliseconds to one second in 947b12a, reducing timeout wakeups by 40× without delaying normal permit handoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] @joewitt follow-up on permit waiting: after reviewing the targeted wake-up approach, we chose to keep the simpler bounded wait and increased the interval from 25 milliseconds to 1 second in 947b12a. tryAcquire still returns immediately when a permit is released, so this does not delay normal permit handoff. It reduces timeout wakeups by 40× while bounding Stop detection at one second when no permit becomes available. All 27 VirtualThreadSchedulingAgentTest tests pass, including the fully contended unschedule case.

@joewitt

joewitt commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
  1. Please keep a system-test path on standard scheduling (or AUTO on Java 21).

What I am asking: the shipped default is now AUTO, which means Java 21 still runs the existing platform-thread scheduler. But every system-test nifi.properties on this branch sets nifi.scheduling.strategy=VIRTUAL (default, pythonic, both cluster nodes). VirtualThreadStartStopCycleIT also forces VIRTUAL. So CI on Java 21 will exercise the new engine, while a real Java 21 install with an untouched config will exercise the old one. There is no system-test graph that starts, stops, clusters, or fails over on STANDARD or on AUTO as Java 21 will actually resolve it.

Why it matters: we now have two production schedulers for a while. Virtual-thread coverage on Java 21 CI is useful bake — I would keep that. What we lose is regression coverage of the path most Java 21 operators will actually run, plus the resolution of AUTO itself (unit-tested only). Start/stop, run-once, terminate, clustered primary-node, and “disable the controller service then delete it” are exactly the races that differ between a delayed-future pool and a long-lived virtual thread. If those only run under VIRTUAL, we will not notice if STANDARD was accidentally broken by the wiring change, and we will not notice if AUTO on Java 21 fails to select standard scheduling in a real process.

A small matrix is enough: leave most shards on VIRTUAL so the new engine gets the heat, and keep at least one Java 21 system-test profile on STANDARD or unset/AUTO. The AUTO resolver already has a unit test; an integration assertion that the running node logged “platform thread pool” on Java 21 and “virtual threads” on Java 25 would close the loop.

  1. Please shrink the leftover FlowEngine when virtual threads are in charge of components.

What I am asking: when VIRTUAL (or AUTO on Java 25) is selected, Processors and Reporting Tasks no longer run on the timer-driven FlowEngine. That pool is still created at nifi.flowcontroller.maxTimerDrivenThreadCount, and raising max threads in the UI still calls setCorePoolSize on it and resizes the virtual-thread semaphore. The “active timer-driven thread” metric is the sum of the two.

Why it matters: today, max threads = 200 means about 200 platform threads that are the component workers. After this change, max threads = 200 means 200 semaphore permits for component work plus a 200-thread platform pool that is only used for a handful of framework periodic tasks (status history snapshot, remote process group refresh, registry sync, Python extension discovery, analytics predictions). Those jobs do not need 200 workers. Pierre raised this earlier, and the earlier branch had the leftover pool as a small framework engine. The current rewrite puts the old size back.

That has three practical effects. Operators who raise max threads to feed a large canvas also stand up a large idle platform pool they no longer need. The thread-count statistic is no longer “how many component tasks are running”; it can show framework pool activity plus permit holders, so a quiet canvas can still look busy, or a busy canvas can look busier than the cap. Shutdown waits on the platform pool first, then the virtual-thread executor, so a large leftover pool can eat the graceful-stop budget before component threads are waited on.

The max-thread property should mean one thing: how many Processor/Reporting Task invocations may run at once. Under virtual-thread scheduling that is the semaphore. The leftover FlowEngine should be a small, fixed framework pool (something like the 8 you had locally before), not resized with max threads, and the active-thread metric should not add the two budgets together as if they were the same cap.

markap14 and others added 2 commits September 11, 2026 16:32
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] @joewitt you were right on both counts. Thank you again for putting this much real effort into the review—and for continuing to spelunk through the scheduler cave with a flashlight and an alarmingly effective goblin detector. This is exactly the kind of quality review that makes a change like this safer.

Addressed in the latest commits:

  • 97d0afe keeps the framework FlowEngine at eight threads when virtual scheduling is active. UI changes to the component limit now resize only the virtual-thread semaphore, and the active component-thread metric no longer includes framework housekeeping work. Standard scheduling still sizes and resizes the platform pool from the configured component limit.
  • d39ac04 changes the default system-test profile to AUTO, so Java 21 exercises standard scheduling and Java 25 exercises virtual scheduling. Clustered and Python profiles remain explicitly VIRTUAL, and VirtualThreadStartStopCycleIT continues to force VIRTUAL for focused coverage.

The 47 focused framework tests pass, as do Checkstyle and PMD. Fresh CI will run the complete Java 21/25 system-test matrix.

@joewitt

joewitt commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

I have never felt so appreciated. " Thank you again for putting this much real effort into the review—and for continuing to spelunk through the scheduler cave with a flashlight and an alarmingly effective goblin detector. This is exactly the kind of quality review that makes a change like this safer." Classic.

@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] The Ubuntu Java 21 integration check failed in the unchanged SMB extension:

org.opentest4j.AssertionFailedError: expected: <1> but was: <0>
    at org.apache.nifi.util.StandardProcessorTestRunner.assertTransferCount(StandardProcessorTestRunner.java:406)
    at org.apache.nifi.processors.smb.ListSmbIT.testInitialListingStrategy(ListSmbIT.java:296)
    at org.apache.nifi.processors.smb.ListSmbIT.testInitialListingStrategyNewFiles(ListSmbIT.java:265)

This is a pre-existing timing flake: the exact same assertion failed on an unrelated PR two days ago, and this branch does not change the SMB processor or its test framework. The test compares millisecond JVM time with timestamps returned by the SMB container; the container timestamp can fall behind the recorded boundary because of its timestamp precision. I have rerun only the failed integration-test workflow.

@github-actions github-actions Bot removed the Stale label Sep 12, 2026
@joewitt

joewitt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Whoa. Testing a GenerateFF to UpdateAttr flow before this PR yields 150,000 EPS on my laptop. With this PR it jumps to 250,000 EPS. The virtual threads provide at least in this scenario dramatically better lock handling/hand-offs. The bottleneck now is just lock contention on the queue/connection when pulling flowfiles. Wow.

@joewitt

joewitt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Put a second pair and can get to 450K EPS which saturates CPU.

@joewitt

joewitt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Follow-on notes from validating NIFI-15862 (virtual-thread scheduling)

These notes are not a review of the scheduling-agent change itself. I have been running Mark’s nifi-15862-virtual-threads work (NIFI-15862 / #11164) as the baseline, with the goal of seeing whether virtual threads actually raise FlowFile/sec on a CPU-bound loop — and then whether leftover engine hotspots, once VT is in play, are worth a later, separate PR.

Nothing below is meant to block or expand this PR. The lock / map / comparator patches are local follow-on experiments.

What we were trying to learn

  1. Does VT scheduling keep the engine busy on a flow that is not disk-bound (0-byte FlowFiles, volatile repos)?
  2. When we stack independent Generate → UpdateAttribute loops, does throughput add, or do the pairs steal from each other?
  3. If they steal, is that the scheduler, virtual-thread pinning, or an older engine lock/allocation path that VTs just make more obvious?

Test setup

  • Build: NiFi 2.12.0-SNAPSHOT on branch nifi-15862-virtual-threads
  • JDK: Azul Zulu 25, macOS, 16 cores
  • Heap: 16g (-Xms16g -Xmx16g)
  • Repos (deliberate, so we are not measuring WAL/content disk):
    • nifi.flowfile.repository.implementation=org.apache.nifi.controller.repository.VolatileFlowFileRepository
    • nifi.provenance.repository.implementation=org.apache.nifi.provenance.VolatileProvenanceRepository
  • Scheduling: nifi.scheduling.strategy=AUTO (virtual threads on this JDK), controller maxTimerDriven=64
  • Flow: one process group with independent pairs of GenerateFlowFile → UpdateAttribute
    • 0-byte FlowFiles
    • Generate batch size 1000
    • no user prioritizers on the connections
    • pair 4 left stopped; three pairs running for the stacked tests
    • concurrent tasks were not uniform (one UpdateAttribute was at 20, others at 5; Generates at 4–5). Extra concurrent tasks on a single pair did not move the needle much once the pair was already saturated.
  • What we refused to do for the benchmark: skip provenance event construction, NoOp provenance, or otherwise disable work the engine would do in a real flow. Volatile repos only avoid persistence, not in-memory event/attribute work.

How we measured

NiFi’s processor status flowFilesOut is a 5-minute rolling window. Dividing that by 300 is a decent steady-state rate after the window is full; a 30-second delta of those counters is wrong once the window is sliding (old seconds drop off → negative “rates”). After a restart, 30-second deltas of the filling 5-minute counters are usable.

We therefore treated:

  • 5-minute Generate out / 300 as the long-run rate
  • 30-second deltas only when the window was filling from a restart, or when we knew the counters were monotonic
  • top / load average / heap from system-diagnostics as the machine picture
  • JFR (profile settings) plus park-stack aggregation to see why CPU stopped scaling

Results on Mark’s code (before any follow-on patches)

Configuration Approx. Generate throughput Notes
1 pair ~163k FF/s, later ~252k FF/s same loop; later number after the JVM/flow had warmed
2 pairs ~526k FF/s (about 252k + 274k) adds — this is the encouraging VT result
3 pairs, extra threads did not add; pairs stole from each other CPU ~660%, disk idle, ~30% sys time

So: virtual threads do let two independent CPU-bound loops run together. A third pair did not buy a third of the machine; the ceiling was not disk and not “we ran out of platform threads.”

JFR (3-pair steal, still fair queue locks)

Ruled out:

  • content/repo disk (idle)
  • virtual-thread pinning (no pinned-thread events in the recording)

Ruled in (hot / park stacks):

  • ReentrantReadWriteLock fair path (hasQueuedPredecessors) on SwappablePriorityQueue put/poll — VT park convoy on every FlowFile
  • provenance attribute handling (unmodifiableMap / enrich)
  • PriorityQueue.siftDown / QueuePrioritizer.compare
  • ConcurrentHashMap / UpdateAttribute attribute-map churn

The scheduler itself was not the thing we were parked on.

Follow-on changes (local, for a later PR)

All of these sit on top of NIFI-15862. They are engine-path tweaks that VTs made expensive because many more tasks actually run the put/poll path at once.

1. Non-fair lock on SwappablePriorityQueuethis is the one that mattered

Change: new ReentrantReadWriteLock(true)new ReentrantReadWriteLock().

Why: FlowFile order is the heap comparator (penalty → user prioritizers → content claim → id), not lock-acquisition FIFO. Fairness was only ordering waiters. With virtual threads, every put/poll that lost the fair lock paid a park/unpark handoff. Three pairs convoyed on that.

Risk we accepted: barge-in / theoretical waiter starvation on a tiny critical section (heap op + size counters). UI/status readLock snapshots can wait a bit longer under a write storm; same class of issue as any non-fair RW lock. We do not lose queue ordering semantics.

Result: three pairs went to ~607k FF/s combined (was fighting itself below the two-pair ~526k). JVM CPU dropped some (~645% → ~563% in one sample; later samples still ~650% depending on heap/GC). Fair-lock scheduling disappeared from the post-fix profile.

This is the change I would most like considered for a follow-on PR. It is small, and it is specifically more important once VT increases concurrent put/poll.

2. Non-fair lock on StandardFlowFileQueue — consistency only

Change: same true → default (non-fair).

Why: leftover twin of (1). Put/poll never take this lock; they go to SwappablePriorityQueue. The outer lock is only session.get(FlowFileFilter) across multiple incoming connections (lock-order deadlock avoidance) and selective drops.

Result: not re-benchmarked on purpose. This Generate → UpdateAttribute loop never acquires it (get() without a filter does not lock-all-queues). No expected throughput change. Fine as the same cleanup if we touch (1).

Note for later, not this experiment: lock() does not wrap the inner queue, so “lock all queues” does not freeze puts/polls on the heap. Separate design leftover.

3. Freeze FlowFile / provenance attribute maps once (Map.copyOf)

Change:

  • StandardFlowFileRecord constructs a frozen map once; getAttributes() returns that instance instead of wrapping with Collections.unmodifiableMap on every call.
  • Builder fromFlowFile still aliases the frozen map and copy-on-writes on mutate (same StackOverflow-avoidance story as today, without wrapping UnmodifiableMap in UnmodifiableMap).
  • StandardProvenanceEventRecord previous/updated attribute maps likewise Map.copyOf at construct.

Why: JFR showed map wrap/copy on the provenance/session path. Intentionally not “skip building the event.”

Result: after this plus (4), 30s Generate rates were ~293k + 158k + 162k ≈ 613k FF/s vs a 5-minute baseline of ~281k + 157k + 157k ≈ 595k immediately before the deploy. That is in the noise of the ~607k we already had from (1). Pair 1 stayed much hotter than pairs 2 and 3 (concurrent-task mismatch, not a comparator bug).

Worth keeping as a real cleanup (and it matches the existing “don’t wrap the map N times” comment), but it is not the VT scaling fix.

4. QueuePrioritizer fast path for the default order

Change: if there are no user prioritizers, neither FlowFile is penalized, and both content claims are null, compare contentClaimOffset then id with Long.compare. Otherwise the existing chain (penalty → user prioritizers → claim → id).

Why: PriorityQueue.siftDown was hot; default order on this flow is claim-null + unpenalized, so id (after offset) decides. We did not replace PriorityQueue with ArrayDeque: default order is not arrival order (penalty → claim/offset → id).

Result: bundled with (3); no clear extra win on this flow.

Tests run on the follow-on patches

  • TestSwappablePriorityQueue (29)
  • TestStandardFlowFileQueue (20)
  • QueuePrioritizerTest (7, including default-order / penalty / claim still honored)
  • TestStandardFlowFileRecord (frozen map + UOE on mutate)
  • StandardProvenanceEventRecordTest (2)

All passed. Not a substitute for a clustered load-balance run or a flow that uses user prioritizers / penalties heavily.

What I would take from this for NIFI-15862 vs a later PR

  • This PR’s VT scheduler looks like it is doing its job on a CPU-bound, 0-byte loop: two pairs add. We did not see pinning. We did not see the scheduler as the 3-pair ceiling.
  • The 3-pair ceiling was fair SwappablePriorityQueue locking interacting badly with many virtual threads pounding put/poll. That lock predates VT; VT just lights it up.
  • I would not fold the follow-on patches into NIFI-15862. They are easier to reason about (and revert) on their own, and only (1) moved throughput.
  • If we do a follow-on, start with (1), optionally (2) as the same one-liner family. (3) and (4) are optional / lower value on this particular flow.
  • Remaining engine cost after (1), still visible in JFR and still not disk: provenance event construction/enrichment, attribute maps, PriorityQueue compares. We left those honest on purpose.

Open questions / caveats

  • Numbers are one Mac, 16 cores, volatile repos, 0-byte files. A WAL + persistent provenance + real content path will look different (and should).
  • Pair imbalance (one loop ~2× another) is at least partly concurrent-task settings, not fully investigated.
  • StandardFlowFileQueue’s outer lock vs inner lock split is a correctness/clarity issue for get(FlowFileFilter), independent of fairness.
  • We should still compare TIMER_DRIVEN (platform threads) vs AUTO/VT on the same flow and same machine if we want a clean “VT vs old scheduler” number. The work above was “VT on, then remove engine bottlenecks VT exposed,” not a head-to-head A/B of the two scheduling agents.

@joewitt

joewitt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Saved at /tmp/NIFI-15862-1000-pair-fairness.md. Copy below:

# Virtual-thread scheduling: 1,000-pair fairness run

Follow-on to the earlier NIFI-15862 notes. Same branch (`nifi-15862-virtual-threads`), same machine, same “do not cheat provenance” rule. This run is about whether the **global permit cap** still gives every component a fair turn when the canvas is far larger than the cap — including mixed scheduling periods.

## Setup

- **NiFi:** 2.12.0-SNAPSHOT, `nifi.scheduling.strategy=AUTO` → virtual threads (Java 25)
- **JDK:** Azul Zulu 25, macOS, 16 cores, heap 16g
- **Repos:** `VolatileFlowFileRepository` + `VolatileProvenanceRepository` (CPU/scheduling, not disk)
- **Controller cap:** Maximum Timer Driven Thread Count = **128** (global `DynamicSemaphore` permits, not a platform pool)
- **Flow:** **1,000 independent pairs** = **2,000 processors**, **1,000 connections**
  - GenerateFlowFile → UpdateAttribute
  - 0-byte FlowFiles, Generate **batch 50**
  - **1 concurrent task** on every processor
  - UpdateAttribute `benchmark=true`, run duration 25 ms, `success` auto-terminated
  - connection backpressure 15,000 / 1 GB
  - layout: 5 pairs across, Generate above UpdateAttribute, same spacing as the original three pairs

Built up in stages (3 → 20 → 220 → 1,000) with the same pair shape so we could see fairness at each scale.

## Scheduling mix (all 1,000 pairs)

Generate and UpdateAttribute in a pair share the same period. Assignment is `n % 10` so cadences are interleaved across the canvas, not parked in one corner.

| Period | Pairs | Share |
|---|---|---|
| 0 sec (as fast as possible) | 100 | 10% |
| 50 ms | 100 | 10% |
| 100 ms | 100 | 10% |
| 500 ms | 100 | 10% |
| 750 ms | 100 | 10% |
| 1 sec | 500 | 50% |

**2,000** scheduled tasks vs **128** permits (~15× oversubscribed).

## Findings

### Uniform pairs (before mixing periods)

At 1+1 concurrent tasks, batch 50, all 0 sec:

| Scale | Combined Generate FF/s | Per-pair | max/min | Notes |
|---|---|---|---|---|
| 20 pairs | ~549k | ~27.2k–27.8k | 1.02 | queues often at 15k; Generate yielded; **not** permit-bound (64 cap unused) |
| 220 pairs | ~630k | 2,858–2,865 | **1.002** | cap **pegged** (~127/128); 1–20 vs 21–220 same mean |
| 1,000 pairs | ~610k | 609.3–610.5 | **1.002** | stdev **0.19 FF/s**; slowest **99.9%** of equal share |

Raising the cap **64 → 128** did **not** double throughput (still ~600k). It let more tasks sit in `onTrigger` at once. Limit is **16 cores + engine work**, not a few pairs hogging the semaphore.

30s deltas of NiFi’s 5-minute counters will go negative when that window is sliding. Compare pairs with **5-minute count / 300**, or treat a uniform delta as “everyone moved together.” Generate **out** vs UpdateAttribute **in** also looks “starved vs busy” in the UI; that is processor type, not unfairness.

### Mixed periods (1,000 pairs)

5-minute averages, ~6 minutes after applying the mix. All 2,000 processors Running. Combined **~600k FF/s**. Generate FF/s ≈ tasks/s × 50 (batch size is real).

| Period | Pairs | Expected Generate Hz | Actual Hz | Of expected | Actual FF/s / pair | Expected FF/s |
|---|---|---|---|---|---|---|
| 0 sec | 100 | max | **86.6** || **4,329** ||
| 50 ms | 100 | 20 | **16.3** | **81%** | 813 | 1,000 |
| 100 ms | 100 | 10 | **9.0** | **90%** | 449 | 500 |
| 500 ms | 100 | 2 | **1.96** | **98%** | 98 | 100 |
| 750 ms | 100 | 1.33 | **1.32** | **99%** | 66 | 67 |
| 1 sec | 500 | 1 | **0.99** | **99%** | 50 | 50 |

Within every bucket, max/min ≈ **1.00**. **Zero** of 500 one-second Generates were below 0.5 Hz or above 1.5 Hz.

Interpretation:

- **Slow pairs are not starved off the board.** 1 s / 750 ms / 500 ms hit their deadlines.
- **50 ms / 100 ms miss some deadlines** because they compete with 100 always-runnable 0-sec pairs.
- Those 0-sec pairs are 10% of pairs (~10% of processors) but held **~97 of ~133** active tasks and **~72%** of FlowFiles/s (433k of 600k). That is expected: the fair permit semaphore gives turns to whoever is runnable; 0-sec is always runnable.
- UpdateAttribute **task** counts on slow pairs are **not** a schedule check. Empty queue uses **bored yield (~10 ms)**, not the 1 s period, so UA tasks include no-work polls. **FlowFiles in** still tracks Generate.

## What this says about VT readiness

1. **The scheduler scales past the cap without collapsing into winners and losers.** 2,000 looping virtual threads vs 128 permits stayed even to three decimals on FlowFiles/s.
2. **Max Timer Driven Thread Count still means something** — it is a global concurrency throttle, not “create 128 platform threads.” Oversubscribe it and combined FF/s stays in the same ~600k band; each pair just gets a thinner slice.
3. **Timer-driven periods still work** under that oversubscription. One-second components kept ~1 Hz. The miss rate on 50–100 ms is permit competition with 0-sec work, the same class of effect you would get from a small platform pool — not VT-specific unfairness.
4. **Yield / no-work is a sleep**, not cancel-and-resubmit. Mixed cadences did not require future juggling. Stop/start of 2,000 processors was routine.
5. **We did not see pinning** on this path in earlier JFR; this 1,000-pair run did not re-profile, but behavior is consistent with “park on permit, run invoke, release.”
6. Combined throughput did **not** grow with pair count (20 → 220 → 1,000). That is **cores + engine** (queues, maps, provenance), not evidence the VT agent failed. Two stacked pairs earlier **did** add; that was the VT win. This run is the fairness/oversubscription win.

## Caveats (same as before)

- One Mac, 16 cores, volatile repos, 0-byte UpdateAttribute loop.
- Not a WAL / persistent provenance / real content soak.
- Not a head-to-head TIMER_DRIVEN vs AUTO A/B at 1,000 pairs.
- Default `AUTO` remains the right upgrade default (VT on Java 25+, platform pool on 21–24). `STANDARD` stays the escape hatch.

**Bottom line:** with 1,000 pairs, mixed periods, and a 128-permit cap, virtual-thread scheduling looked **ready** for the property it was meant to have — many concurrent tasks, a simple global bound, and a fair shake for slow timers rather than a few 0-sec processors owning the engine.

@joewitt

joewitt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

ok @markap14 this was a lot of fun to review and experiment with. While I dont feel confident enough abou the true code itself and would like someone such as @exceptionfactory to give it a final review - i am +1 at this point after reasonably detailed testing and experimentation. Various results shared about. The first large markdown dump is from going beyond this and suggesting some follow-on changes. The second big block is from really focusing on behavior of scheduling/thread starvation/timing concerns. It holds up well.

I did my testing on Java 25. I didn't focus on Java 21. Since we are default off on Java 21 and default on with Java 25 I think that is fine.

I also ran the entire test suite/portfolio over the span of a copule hours and everything came up green.

@exceptionfactory exceptionfactory left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work on this @markap14, this looks close to completion. I noted a few minor recommendations, and a couple questions regarding Thread-tracking for diagnostics.

Comment thread nifi-docs/src/main/asciidoc/administration-guide.adoc Outdated
Comment thread nifi-framework-api/src/main/java/org/apache/nifi/diagnostics/ThreadDumpTask.java Outdated
waitForDelay(sleepNanos, generation);
}

private boolean acquirePermitWithPolling(final LifecycleState lifecycleState, final SchedulingGeneration generation) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any synchronization concerns with this method being called from a stop and from runOnce? It seems possible to have a race condition based on the generation, but not quite clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GPT-5.6 Sol] I walked through the Stop and Run Once interleavings and this is safe. Registration and explicit unscheduling are serialized on LifecycleState. Cleanup uses ConcurrentMap.remove(componentId, generation), so completion of an older Run Once cannot remove a newer registered generation. It then stops only the captured generation, and SchedulingGeneration.stop() is idempotent. No synchronization change is needed here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply. For clarity, I was referring to a race between acquirePermitWithPolling() and invoke(). Can you take a closer look at the relationship between those method calls?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying. You were right: the final active check in acquirePermitWithPolling() could succeed, followed by an unschedule before ConnectableTask registered the invocation as active. ConnectableTask now calls LifecycleState.tryIncrementActiveThreadCount(), which checks the scheduled state and registers the invocation under the same lifecycle monitor used by unscheduling. If unscheduling wins, onTrigger() is skipped; if registration wins, the stop sequence sees an active invocation and waits for it. I added a deterministic regression test that pauses after permit acquisition and confirmed it failed with one invocation before this correction and now passes with zero.

Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] The Scan check failed before project compilation while resolving the parent POM:

[FATAL] Non-resolvable parent POM for org.apache.nifi:nifi:2.13.0-SNAPSHOT:
The following artifacts could not be resolved: org.apache:apache:pom:39 (absent):
Could not find artifact org.apache:apache:pom:39 in central (https://repo.maven.apache.org/maven2)

This is a transient dependency-resolution failure unrelated to this branch. Apache parent 39 comes from current main, and the current main Scan resolved the same parent and proceeded through Maven Package. The workflow is still running its sibling Validate job; I will rerun only the failed Scan job as soon as the workflow completes.

@markap14

Copy link
Copy Markdown
Contributor Author

[GPT-5.6 Sol] The workflow has completed, so I have now rerun the failed Scan check. As noted above, the original attempt failed before compilation when Maven Central temporarily reported org.apache:apache:pom:39 as absent.

@exceptionfactory exceptionfactory left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates @markap14, this looks close to completion. I noted a couple remaining items

waitForDelay(sleepNanos, generation);
}

private boolean acquirePermitWithPolling(final LifecycleState lifecycleState, final SchedulingGeneration generation) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply. For clarity, I was referring to a race between acquirePermitWithPolling() and invoke(). Can you take a closer look at the relationship between those method calls?

Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[gpt-sol-5.6] The macos-15 Java 21 system-test shard failed in ClusteredConnectorTroubleshootingIT.testConfigurationAndAuthoritativeFlowRestoredAfterTroubleshootingRestart:

org.opentest4j.AssertionFailedError: Running Connector must write the configured sensitive value to the configured Sensitive Output File ==> expected: <configured-secret-value> but was: <>
    at org.apache.nifi.tests.system.connectors.ConnectorTroubleshootingIT.testConfigurationAndAuthoritativeFlowRestoredAfterTroubleshootingRestart(ConnectorTroubleshootingIT.java:1036)
    at java.base/java.lang.reflect.Method.invoke(Method.java:580)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)

I downloaded and checked the troubleshooting archive. This is a pre-existing timing flaw in this unrelated Connector test: it waits only for both shared output files to exist before reading them, while either clustered node can create or truncate a file before writing its content. The node logs show both connectors starting successfully and both nodes subsequently writing 23 bytes to the same sensitive-output path, with no NiFi error. I have rerun only the failed shard.

Co-authored-by: Cursor <cursoragent@cursor.com>
@markap14

Copy link
Copy Markdown
Contributor Author

[gpt-sol-5.6] The Ubuntu Java 25 system-test job hit a pre-existing OffloadIT.testOffload failure. The same test timed out on main yesterday in run 35155156816. The failure reported:

java.util.concurrent.TimeoutException: testOffload() timed out after 10 minutes
    at org.apache.nifi.tests.system.NiFiSystemIT.waitForNodeStatus(NiFiSystemIT.java:472)
    at org.apache.nifi.tests.system.clustering.OffloadIT.testIteration(OffloadIT.java:87)

The troubleshooting logs show the Processors stopped successfully in 111 ms. During the offload, cluster coordinator leadership moved from node 2 to node 1; node 2 completed its local offload but could not change its local coordinator state from DISCONNECTED to OFFLOADED. This is unrelated to the scheduling-generation check in the latest commit. I will rerun the failed job after the remaining jobs in this workflow finish.

@markap14

Copy link
Copy Markdown
Contributor Author

[gpt-sol-5.6] I have rerun the failed Ubuntu Java 25 system-test job. As noted above, the saved logs show that Processor stopping completed successfully and the timeout followed a cluster coordinator change during node offload; the same OffloadIT.testOffload timeout occurred on main yesterday.

@exceptionfactory exceptionfactory left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work on this @markap14, and thanks for the helpful reviews @joewitt and @pvillard31, the latest version looks good!

@exceptionfactory
exceptionfactory merged commit d352f84 into apache:main Sep 18, 2026
17 of 18 checks passed
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.

4 participants