Skip to content

Add a new API to update the min & max thread count dynamically#3658

Merged
nateberkopec merged 1 commit intopuma:mainfrom
yuki24:yuki24/add-api-to-change-thread-min-max
Feb 18, 2026
Merged

Add a new API to update the min & max thread count dynamically#3658
nateberkopec merged 1 commit intopuma:mainfrom
yuki24:yuki24/add-api-to-change-thread-min-max

Conversation

@yuki24
Copy link
Copy Markdown
Contributor

@yuki24 yuki24 commented May 23, 2025

Description

@nateberkopec and I are working on something that automatically identifies the optional worker count on the fly and updates the thread count accordingly and dynamically. The algorithm we are actively working on will be proprietary, but we think that it makes sense to add an API to dynamically change the thread count could be part of the open source Puma, hence the PR here.

I have looked into Puma's source code, and noticed that this isn't extremely complicated, as Puma's thread pool already looks at the min and max to not allocate too many threads or trim unused threads dynamically. What this means is that in order to achieve what we want to do, we can simply set a new number to min or/and max.

This PR includes a simple change where #update_worker_count was added to do that. The accessors for min and max have also been added to the ThreadPool. I know this may seem a bit scary, but that's basically what dynamic update means so I thought it might be fine.

Please let me know what you all think.

Your checklist for this pull request

  • I have reviewed the guidelines for contributing to this repository.
  • I have added (or updated) appropriate tests if this PR fixes a bug or adds a feature.
  • My pull request is 100 lines added/removed or less so that it can be easily reviewed.
  • If this PR doesn't need tests (docs change), I added [ci skip] to the title of the PR.
  • If this closes any issues, I have added "Closes #issue" to the PR description or my commit messages.
  • I have updated the documentation accordingly.
  • All new and existing tests passed, including Rubocop.

@github-actions github-actions Bot added the waiting-for-review Waiting on review from anyone label May 23, 2025
@dentarg
Copy link
Copy Markdown
Member

dentarg commented May 26, 2025

Is update_worker_count a good name? Could it be confused with worker processes?

How would one use this? Could you show an example dummy app using this?

@MSP-Greg
Copy link
Copy Markdown
Member

As @dentarg mentioned.

Maybe change update_worker_count to update_thread_pool_min_max? I've never liked ThreadPool related methods using the term worker.

Re an example, it's not clear how to use this with several workers running (just a quick look)...

@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch 3 times, most recently from 27e8607 to 1a3a682 Compare July 4, 2025 07:43
@yuki24
Copy link
Copy Markdown
Contributor Author

yuki24 commented Jul 4, 2025

Thanks for your replies, @dentarg and @MSP-Greg! And apologies for a long delay. I have updated the method name to update_thread_pool_min_max. I am open to suggestions for a better name if you have any.

With regard to how we use this method, we set up a timer that periodically calculates the optimal worker count based on the metrics we collect with the gvl_timing gem, and then we call update_thread_pool_min_max to adjust the thread pool size. The code snippet below shows how we do this:

class OurAwesomeGem
  ...

  def start_timer
    @timer ||= Thread.new do
      loop do
        avg_duration, avg_io, avg_gvl = average_metrics_from_gvl_timing

        max = calculate_optimal_worker_count(avg_duration, avg_io, avg_gvl)

        # This is a `Puma::Server` object. `min` could always be 1, so this is setting `max` only.
        @server.update_thread_pool_min_max(max: max)

        sleep interval
      end
    end
  end
  
  ...
end

In order to grab the Puma::Server object, we use a Puma plugin that hooks into the Puma lifecycle events. The code
for the plugin is as follows:

# our_awesome_gem/lib/puma/plugin/our_awesome_gem.rb so this could be loaded with
# `--plugin our_awesome_gem`:
Puma::Plugin.create do
  def start(launcher)
    launcher.config.configure do |user_dsl, *|
      if launcher.config.options[:workers] > 0
        # Cluster mode:
        user_dsl.on_worker_boot do
          OurAwesomeGem.start_timer
        end

        user_dsl.on_thread_start do
          OurAwesomeGem.puma_server ||= Puma::Server.current
        end
      else
        # Single mode:
        user_dsl.on_booted do
          OurAwesomeGem.puma_server = Puma::Server.current
          OurAwesomeGem.start_timer
        end
      end
    end
  end
end

The second commit in this PR 1a3a682 has been added. Without this the Puma::Server.current would return nil in the on_thread_start hook when running in cluster mode. Similarly, the same call in the on_booted hook would return nil in single mode without 1a3a682.


Another thing I noticed was that It seems like Puma's lifecycle events and hooks had been added as people needed them, and there seems to be room for improvement in the API design. I wonder if we could think about how to make these events and hooks more predictable, regardless of cluster/single mode? Issues like sidekiq/sidekiq#6661 have already been reported, so it would be great if I could get some feedback on this.

@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch 2 times, most recently from 05cc3de to 7384c68 Compare August 26, 2025 03:29
@MSP-Greg
Copy link
Copy Markdown
Member

@yuki24

Not sure if there was a reason for not doing so, if not, please rebase? I can have a look tomorrow. We may need an 'integration' test for this, especially if a clustered test is done...

@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch from b0a1985 to 16a26f2 Compare August 26, 2025 13:16
@MSP-Greg
Copy link
Copy Markdown
Member

MSP-Greg commented Aug 26, 2025

@yuki24

Good day. By using the following, each request is sent, the response is generated, and the 'client' reads it. Then the next request is sent. So, it's not filling up the server with a 'backlog' of requests.

10.times { send_http_read_response(GET_11) }

The following does create a 'backlog' and the test passes. It creates the requests with a small delay in the app.

  def test_update_thread_pool_min_max
    @app = ->(env) do
      sleep 0.001
      [200, {}, [env['rack.url_scheme']]]
    end

    server_run(min_threads: 1, max_threads: 1, auto_trim_time: 1)

    assert_equal 1, @server.stats[:running]

    @server.update_thread_pool_min_max(min: 3, max: 3)

    # By making multiple requests, we can ensure that the pool is filled up to the max.
    req_ary = send_http_array 20
    resp_ary = req_ary.map { |req| req.read_response }

    assert_equal 3, @pool.max
    assert_equal 3, @pool.min
    assert_equal 3, @server.max_threads
    assert_equal 3, @server.min_threads
    assert_equal 3, @pool&.spawned
  end

EDIT:

I should probably have more coffee, but if one increases the ThreadPool#min, I'm not sure if it will affect a server that's 'idle'.

@MSP-Greg
Copy link
Copy Markdown
Member

MSP-Greg commented Aug 26, 2025

@yuki24 and @nateberkopec

I pushed the test fix, CI is passing. Question, and this pertains to the PR, and maybe Puma:

What happens if a min value is set higher than the max value? A warning would certainly be expected, the min value could be ignored, or the max could be set to the new min value. Not.sure.

EDIT: also, what happens if max is set lower than min? Again, not sure if this is covered

Comment thread lib/puma/server.rb Outdated
@github-actions github-actions Bot added waiting-for-changes Waiting on changes from the requestor and removed waiting-for-review Waiting on review from anyone labels Aug 27, 2025
Comment thread lib/puma/thread_pool.rb Outdated
@yuki24
Copy link
Copy Markdown
Contributor Author

yuki24 commented Aug 29, 2025

@MSP-Greg Thank you for reviewing! Really appreciated the test fix ❤️

What happens if a min value is set higher than the max value? A warning would certainly be expected, the min value could be ignored, or the max could be set to the new min value. Not.sure.

EDIT: also, what happens if max is set lower than min? Again, not sure if this is covered

I just merged your suggestion. I also think we may want to raise an exception in both the cases. At the same time, I don't think I fully understand the risk of raising an exception from within a web server. It's probably safer to just warn than an exception. What do you think?

@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch 6 times, most recently from 13a0d74 to 450e9b5 Compare September 1, 2025 12:13
@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch 2 times, most recently from 4841059 to 5ec8349 Compare September 14, 2025 06:52
schneems added a commit that referenced this pull request Sep 17, 2025
There are proposals to make thread counts dynamic RE #3658. If this happens we need to pull in the current value, and cannot rely on it being set once.
schneems added a commit that referenced this pull request Sep 18, 2025
* Move cluster logic to its own class

Simplify the calculation. This is an alternative to #3723.

Puma 6.6.1 behavior documented here https://gist.github.com/schneems/cef38e9448dfb72943d13050a7da0869

This changes the puma 7 behavior to:

- Starts sleeping the event loop before being overloaded (the prior `pool.busy_threads` included in the ready @todo queue) and would only fire. This is closer to puma 6.6.1 behavior
- Sleeps a consistent proportional amount, closer to puma 7 behavior, versus puma 6 used a static value.
- Sleeping behavior is restricted to 2 or more workers, verus puma 7 was accidentally enabled for clusters with only 1 worker

Fixes #3740. This `wait_for_less_busy_worker` value is now treated as a maximum value for the sleep calculation. It also now respects a value of 0 to mean "don't sleep at all" which was the prior behavior of that value.

* Whitespace

* Remove unused method

The busy_thread call is now accessed directly through the thread pool and only used in one place.

* Add docs about server <-> reactor relationship

The code doesn't make it clear who is calling whom when looking at it in isolation.

* Make maximum 25x thread count and allow for overage

Puma can take in more requests than it has threads. This change preserves the same logic as before, but it doesn't hit maximum sleep until it is at 25x the number of max threads. That means that if a server was using 5 threads,  before it would hit 0.005 sleep if those five threads were busy, now if (only) five threads are busy it will sleep 0.0002 seconds.

* Move order of comparison

If busy threads is zero we don't need to calculate percentage.

* Refactor out clamp

Because we're clamped at the numerator, the division will naturally tend towards 1.0. We don't need a second clamp on the result. This allows us to remove an intermediate variable and multiply directly on the return calculation.

* Lazy max_threads

There are proposals to make thread counts dynamic RE #3658. If this happens we need to pull in the current value, and cannot rely on it being set once.

* Avoid re-checking the `max_delay` number every iteration

* Refactor out branching

The equation already holds up the tested properties without needing to special-case when busy threads is zero. Removes a branching conditional.

* Push worker logic into delay class

The logic of whether or not the calculation should be performed based on worker count can be calculated once and not repeated on every iteration.

* Optimize case where no threads are busy

Moves the condition from checking if the delay is zero to checking if the input is zero earlier. This is prevents some calculations and preserves the same number of conditionals.
@github-actions github-actions Bot removed the waiting-for-changes Waiting on changes from the requestor label Sep 22, 2025
@nateberkopec nateberkopec added waiting-for-review Waiting on review from anyone and removed waiting-for-merge labels Jan 20, 2026
@nateberkopec nateberkopec force-pushed the yuki24/add-api-to-change-thread-min-max branch from 8d75bbe to cbe7fd0 Compare January 20, 2026 00:35
@github-actions github-actions Bot added waiting-for-changes Waiting on changes from the requestor and removed waiting-for-review Waiting on review from anyone labels Jan 20, 2026
@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch from cbe7fd0 to a9da027 Compare January 22, 2026 02:42
@yuki24 yuki24 requested a review from schneems January 22, 2026 07:17
@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch from a9da027 to bc71679 Compare January 22, 2026 07:18
Copy link
Copy Markdown
Member

@nateberkopec nateberkopec left a comment

Choose a reason for hiding this comment

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

Two issues block this change for typical usage: (1) ServerPluginControl is created before server.thread_pool is assigned, so defaulting min/max via @thread_pool raises in before_thread_start; (2) update_thread_pool_min_max rejects min==0, which is the default config.

Comment thread lib/puma/server_plugin_control.rb
Comment thread lib/puma/server.rb
@nateberkopec
Copy link
Copy Markdown
Member

nateberkopec commented Jan 27, 2026

For posterity: codex 5.2 xhigh found those two issues, I watched it verify them manually, and I attest they are correct.

@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch from bc71679 to 1b510ab Compare February 3, 2026 12:21
@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch 2 times, most recently from 02581a7 to cf6dcd4 Compare February 11, 2026 05:55
@nateberkopec nateberkopec self-assigned this Feb 11, 2026
@github-actions github-actions Bot added waiting-for-merge and removed waiting-for-changes Waiting on changes from the requestor labels Feb 12, 2026
Copy link
Copy Markdown
Member

@schneems schneems left a comment

Choose a reason for hiding this comment

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

No major issues, made some suggestions, please apply before merging or suggest alternatives.

Comment thread lib/puma/server.rb
Comment thread test/test_puma_server.rb
@github-actions github-actions Bot added waiting-for-review Waiting on review from anyone and removed waiting-for-merge labels Feb 13, 2026
@yuki24 yuki24 force-pushed the yuki24/add-api-to-change-thread-min-max branch from c8ccef5 to f15cf94 Compare February 13, 2026 03:23
@yuki24
Copy link
Copy Markdown
Contributor Author

yuki24 commented Feb 13, 2026

Addressed the comments and rebased against main and the builds are all ✅ Let me know if there is anything else I can do here.

@github-actions github-actions Bot added waiting-for-merge and removed waiting-for-review Waiting on review from anyone labels Feb 13, 2026
@nateberkopec nateberkopec merged commit 20759a2 into puma:main Feb 18, 2026
80 checks passed
@yuki24 yuki24 deleted the yuki24/add-api-to-change-thread-min-max branch February 18, 2026 03:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants