Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rack.response_finished to Rack::Lint #1802

Closed
wants to merge 9 commits into from

Conversation

BlakeWilliams
Copy link

@BlakeWilliams BlakeWilliams commented Feb 4, 2022

This updates Rack::Lint to validate that rack.response_finished is an
array of callables when present in the env. e.g. procs, lambdas, or
objects that respond to call.

This validates that:

  • rack.response_finished is an array
  • The contents of the array all respond to call

Part of #1777

lib/rack/lint.rb Outdated Show resolved Hide resolved
lib/rack/lint.rb Outdated Show resolved Hide resolved
@BlakeWilliams
Copy link
Author

Rack::Lint seems to validate that middleware implements the spec correctly but rack.response_finished is completely dependent on the server implementation. Is there a standard way to validate that a server implements specific behavior?

@jeremyevans
Copy link
Contributor

Rack::Lint seems to validate that middleware implements the spec correctly but rack.response_finished is completely dependent on the server implementation. Is there a standard way to validate that a server implements specific behavior?

No. Rack::Lint doesn't validate that a server handles responses correctly, it only validates inputs (env) and outputs ([status, headers, body]). This implementation looks fine to me. I think we should keep the rack.after_reply name, as Unicorn and Puma already implement it. If we are adding something to SPEC that is already supported and de facto standardized by multiple implementations, we should use the existing name, even if we think our name is better.

@ioquatix
Copy link
Member

Is rack.after_reply compatible with the proposed spec?

@jeremyevans
Copy link
Contributor

Is rack.after_reply compatible with the proposed spec?

Other than the key, it seems to be compatible:

  • Unicorn: env["rack.after_reply"].each(&:call)
  • Puma: after_reply.each { |o| o.call }

There is no advantage in adding a different name for behavior already de facto standardized in existing webservers.

@ioquatix
Copy link
Member

@jeremyevans I understand your point.

However, since we are NOW formalising it, we could change the name. I don't think after_reply is very meaningful. Whether the value of using an existing defacto-standard outweighs the name, I'd personally vote for the latter. But I understand the former. It's just that I care more about the future than the past.

@jeremyevans
Copy link
Contributor

Unless we can identify significant problems, I believe we should adopt existing de facto standards as already implemented. @tenderlove agrees (#1701 (comment)):

If popular web servers all do it, then it's really the defacto spec. Rack's job is to define the interface, and if all web servers agree this is a fine interface then I think we're good to go?

Here the question is just about what env key to use, which is purely a bikeshed issue. The bikeshed is already painted, there is no point in painting it a different color, even if the different color looks nicer.

@ioquatix
Copy link
Member

@jeremyevans that's not what @tenderlove said: #1777 (comment)

@ioquatix
Copy link
Member

I would like to add, we CAN validate that these are called by the server, by wrapping them - the same way we validate the body was either called or enumerated IIRC.

@jeremyevans
Copy link
Contributor

@jeremyevans that's not what @tenderlove said: #1777 (comment)

What I quoted is what @tenderlove said in issue #1701. It is a general statement about how we should treat a de facto spec. It looks like what @tenderlove states in #1777 is in favor of this particular case, even though this case goes against his advice in #1701. I will leave it to @tenderlove to explain why the general statement does not apply to this particular case.

I would like to add, we CAN validate that these are called by the server, by wrapping them - the same way we validate the body was either called or enumerated IIRC.

I suppose we could do that, but I don't see the advantage. Lint is not about validating how servers handle responses, merely that the response provided to the server is in the correct format.

Even assuming we wanted Lint to do this, when could you actually validate them? You could use a timeout that fails if it isn't called. How long would such a timeout be? Would it start when the body was closed? Not sure how such a timeout can reliably raise an appropriate exception, and I think it's a fools errand to try such an approach

@ioquatix
Copy link
Member

ioquatix commented Apr 4, 2022

@jeremyevans I think these are all really good questions. Does the design being hard to test indicate a more fundamental issue?

All the issues you mention seem like reasonable things... like, when does this execute? After 1 year? When the server shuts down? Should we allow it to be deferred? Should it run on a background thread? Does this indicate design issues we need to address?

This updates Rack::Lint to validate that `rack.response_finished` is an
array of callables when present in the `env`. e.g. procs, lambdas, or
objects that respond to `call`.

This validates that:

* `rack.response_finished` is an array
* The contents of the array all respond to `call`
@BlakeWilliams
Copy link
Author

All the issues you mention seem like reasonable things... like, when does this execute? After 1 year? When the server shuts down? Should we allow it to be deferred? Should it run on a background thread? Does this indicate design issues we need to address?

I can attempt to answer some of these questions in the spec. I'll try adding some details about how/when these callables should be called under rack.response_finished in SPEC.rdoc, but happy to add it elsewhere if there's a better place.

I'm also happy to revert the name back to rack.after_reply if that's what we want to do. There's a good amount of value keeping the old name since existing implementations will continue to work and servers won't have to go through a full deprecation process due to the name change. Happy to defer to y'all though.

@jeremyevans
Copy link
Contributor

@tenderlove I think your guidance is needed here to decide whether we should officially bless the de facto standard rack.after_reply (implemented by Puma and Unicorn), or standardize a different (perhaps more accurate) rack.response_finished. The semantics will be the same in both cases, so this just depends on whether the benefit of a potentially more accurate name outweighs the cost of forcing changes in Puma, Unicorn, and apps that currently use rack.after_reply with Puma or Unicorn.

@ioquatix
Copy link
Member

ioquatix commented Apr 5, 2022

I'm fine with either name, but my issues would be:

  1. It means we are stuck adding a spec for whatever servers currently implement (and may be unwilling to change) so we don't have any flexibility to actually define what we want rather than what we already have (which may or may not be good). If we define "after_reply" we need to make sure linting is good enough to ensure they are compliant.
  2. "response" is a more canonical HTTP terminology/concept than "reply" so I think it has a clearer meaning.
  3. "response_finished" to me means feels like a more precise definition of "the point exactly after response has completed sending to the client". "after_reply" seems less well defined but maybe I'm just being too fussy.

The way I see it, this is a more efficient handler for Rack::BodyProxy. The typical usage I imagine would be things like closing database handles or indicating other resources can be freed.

It's not semantically more complex than what we have, but it exposes a 2nd way to achieve the same thing which users should be concerned about. Every env should have this rack.after_reply/rack.response_finished. That means any kind of internal/virtual request (e.g. an app which does several internal requests or maybe does a HEAD request to determine cachability) should add this and execute those callbacks as required... Or is it acceptable to share the rack.after_reply/rack.response_finished to internal requests?

Considering Rack::BodyProxy... in other words, is it acceptable to make a request and not read/close the body? I think it's probably unsafe. So we need to be really clear on how this should factor into the overall life cycle of Rack requests and the expectations of applications (if we make this a mandatory feature/requirement). It also obviously also impacts rack-test type gems which do mock requests.

Finally my last concern is one of responsibility. env provides keys with mutable values. env is considered "request" or "input" state. But we are using it to provide "output" state. It seems like a hack. Maybe it's impossible to fix with rack or maybe my interpretation/definition is wrong. But do we want to encourage this kind of design where env can be stateful. Rack hijack is an example of this and the original interface was pretty messed up since it actually updates env with rack.hijack_io. However in the case of virtual/internal requests, the env object might be a different hash instance than the one the server originally created, leading to confusion. The more we go down the path of env is the request "state", the more we encourage this kind of design, the harder it will be to do anything different. I'm not sure it's a problem, it might just be the state we are in, and I think it's worth commenting that by accepting this PR, we are encouraging more "output" state in "env" which I'm not entirely comfortable with from a complexity POV.

@ioquatix ioquatix added the SPEC label Apr 8, 2022
@BlakeWilliams BlakeWilliams marked this pull request as ready for review April 8, 2022 14:14
SPEC.rdoc Outdated Show resolved Hide resolved
Co-authored-by: Samuel Williams <samuel.williams@oriontransfer.co.nz>
@tenderlove
Copy link
Member

I like the name rack.response_finished. It makes sense that the callbacks are an array. I'm not really in favor of being able to skip callbacks, and if a callback raises an exception I'd still like the rest of the callbacks to be called.

The usecase I think of is closing database connections after a request is finished. If some callback said everything should be skipped, it might mean leaked connections, and that wouldn't be a good thing.

Open ended question: do we want to do anything with the return value of the callbacks, and do we want to pass anything to them? Or should we just punt that for later?

@BlakeWilliams I think this is a great feature and I'd like to see it in Rack 3.0 (If you have the time of course!!)

@ioquatix ioquatix added this to the v3.0.0 milestone Jun 25, 2022
@ioquatix
Copy link
Member

ioquatix commented Jul 30, 2022

I started playing around with real implementations and realised we are missing a critically useful part which is the ability to feed any client errors back to the callback. I actually don’t mind what we call it but after actually trying it I want something better. Semantically this isn’t about a response or reply or even when it’s finished. It can also be when there is an error and for internal or virtual requests it has nothing to do with an actual response or reply.

After investigating the naming scheme, the rack.response prefix is inconsistent with rack.request usage. So I want to propose rack.closed which is semantically the same as body.close (using past tense since it’s after the fact) except it is always called even if there is a client error which is given as an optional argument. This is fundamentally advantageous over the existing approach, since before there was no way to tell if a client disconnected or the response failed for some other reason. In the case of an error I think after_reply or response.finished is misleading.

My updated proposal is this PR + replaces env with an options error argument.

@ioquatix
Copy link
Member

ioquatix commented Jul 30, 2022

Also if it was up to me I’d seriously consider adding an argument to body.close so we could pass through an error. This can be critically
Important for some usages like websockets and streaming. But alas the backwards compatibility might be hard.

@ioquatix
Copy link
Member

ioquatix commented Jul 30, 2022

By the way I don't want to seem like I'm trying to make a unilateral decision, but I can't add commits to this PR. I was planning on merging this PR largely as is, but found that in practice it's not sufficient and ended up squashing all my commits on the new PR. Anyway, feel free to re-open this PR if we want to discuss it or work on this design further, that's totally fine, I just didn't feel comfortable asking @BlakeWilliams to continue working on something when I don't really know what I want to propose yet - I need to spend some time working through the implementation in Falcon too.

@jeremyevans
Copy link
Contributor

Reopening as #1932 was closed.

@jeremyevans jeremyevans reopened this Aug 1, 2022
@ioquatix ioquatix modified the milestones: v3.0.0, v3.1.0 Aug 1, 2022
@ioquatix
Copy link
Member

ioquatix commented Aug 5, 2022

There has been a lot of discussion on this.

I'm a strong believer that this needs to be compatible with the existing semantics where possible, and I'm no longer convinced this should be a required feature. I changed my opinion after implementing it in Falcon and reviewing existing use cases.

The main use cases we have been informed of is:

  1. GitHub using it for flushing metrics after the response was sent.
  2. Rails releasing resources after a response was generated/sent.

Both use cases would be satisfied by an optional rack.after_reply which could be relatively efficiently implemented by:

module Rack
  def self.Ensure(env, app, &block)
    if after_reply = env['rack.after_reply']
      after_reply << block

      return app.call(env)
    else
      begin
        response = app.call(env)
        response[2] = BodyProxy.new(response[2]) {block.call}
      rescue => error
        block.call(error)
        raise error
      end
    end
  end
end

response = Rack::Ensure(env, @app) do |error|
  # Best effort to be called, error may be set if the response failed to be sent.
end

Assuming no one will agree with Rack::Ensure as a name, we could also consider Rack.call(...), Rack.after(...), Rack::BodyProxy.after(...) etc. Something short and easy for users to understand would be ideal.

The biggest limitation of this is the inability to provide error information to the callback when the BodyProxy semantic is used. I'm not sure if I care much about this, but I think it's a big reason why servers might choose to implement after_reply.

This code above also hopefully demonstrates that there are semantic differences. We could have more elaborate exception handling in the after_reply branch to better match the BodyProxy implementation... not sure what is better.

I would hypothesize that there is little performance difference between the use of BodyProxy and rack.after_response. It would be nice to have some evidence. If there is no performance difference, does it reduce the value of this specification?

@ioquatix
Copy link
Member

ioquatix commented Aug 5, 2022

If we are willing to bend the rules a little bit:

response[2] = BodyProxy.new(response[2]) {block.call($!)}

could be acceptable / best effort.

@ioquatix
Copy link
Member

ioquatix commented Aug 5, 2022

I couldn't help but flesh this out. Not convinced by any of the method names or rack.after_reply itself, but I think this is a reasonable semantic. Zero-cost if not in use.

module Rack
  # Usage:
  #   # In config.ru:
  #   use Rack::AfterReply # unless feature?(:after_reply)
  #
  #   # In application:
  #   response = Rack::AfterReply.call(env, @app) do |error|
  #     # Best effort to be called, error may be set if the response failed to be sent.
  #   end
  class AfterReply
    def initialize(app)
      @app = app
    end

    RACK_AFTER_REPLY = 'rack.after_reply'

    # Expected to be called by the server.
    def self.apply(env, error)
      return unless after_reply = env[RACK_AFTER_REPLY]

      while callback = after_reply.pop
        begin
          callback.call(error)
        rescue => callback_error
          env['rack.errors'].puts(callback_error.full_message)
        end
      end
    end

    # Provides a defacto implementation for servers that don't support it.
    def call(env)
      env[RACK_AFTER_REPLY] ||= []
      
      @app.call(env)
    rescue => error
    ensure
      self.class.apply(env, error)
    end

    # Expected to be used by the middleware/application:
    def self.call(env, app, &block)
      if after_reply = env[RACK_AFTER_REPLY]
        after_reply << block
        return app.call(env)
      else
        begin
          response = app.call(env)
          response[2] = BodyProxy.new(response[2]) {block.call($!)}
          return response
        rescue => error
          block.call(error)
          raise error
        end
      end
    end
  end
end

@BlakeWilliams
Copy link
Author

The main use cases we have been informed of is:

  1. GitHub using it for flushing metrics after the response was sent.
  2. Rails releasing resources after a response was generated/sent.

GitHub is also using it in a few other places, like persisting some cache values at the end of a request which ended up making some of our pages a good bit more performant iirc. Puma has also been utilizing this since 2015 for logs using the commonlogger here: puma/puma@537bc21#diff-aaa1392bef19f789281e5b8c8650529f5caf26a139df7a32631da6592dbe25d3R38

The biggest limitation of this is the inability to provide error information to the callback when the BodyProxy semantic is used. I'm not sure if I care much about this, but I think it's a big reason why servers might choose to implement after_reply.

I'm not quite convinced that we need error information in the callbacks, at least based on the use-cases I've run into. Typically by the time you've created the callback I think it's reasonable to expect that the callback will run, regardless of error status. e.g. If we want to flush metrics at the end of a request, or close some kind of connection to a service, it doesn't matter if the client closed the connection.

I would hypothesize that there is little performance difference between the use of BodyProxy and rack.after_response. It would be nice to have some evidence. If there is no performance difference, does it reduce the value of this specification?

I think it would reduce some of the value, but there's also value in having an explicit API for this behavior that's at least a bit more ergonomic than using BodyProxy. It doesn't feel great that you have to replace a result in the response array, and it limits where you can create callbacks (for better or worse).

Also, when I originally wanted to implement the stats flushing behavior I used Rack::Events which I believe uses some form of BodyProxy under the hood. Unfortunately I found that it would leave the request open until the provided on_finish callbacks completed. That means that in some scenarios, it's possible that BodyProxy might not provide the functionality you'd expect to get from it. The docs seemed to indicate that it would run after the response, despite it not doing so in at least our configuration:

The webserver has closed the response, and all data has been written to the response socket. The request and response object should both be read-only at this point. The body MAY NOT be available on the response object as it may have been flushed to the socket.

@BlakeWilliams
Copy link
Author

I'm a strong believer that this needs to be compatible with the existing semantics where possible, and I'm no longer convinced this should be a required feature. I changed my opinion after implementing it in Falcon and reviewing existing use cases.

For what it's worth, both Puma and Unicorn support this functionality today, which results in a pretty large number of applications having rack.after_reply available, but as an unofficial extension (disclaimer, I implemented the version in Unicorn).

The primary reasoning I had for opening this issue and this PR is that it would be ideal if we could make the implementation of those two webservers an official spec, since it is providing value to existing applications. By making it required/official, it means applications depending on that behavior are now able to more easily swap between server implementations if they desired (barring other issues, like thread safety) and have a slightly more consistent feature-set available. I'd find it unfortunate if we decided not to move forward with some variation on this functionality, but it would still exist in Puma/Unicorn and be usable as-is going forward.

@BlakeWilliams
Copy link
Author

oh, there was also this gem, maybe_later that I remembered that also utilizes this functionality. 🙂

@ioquatix
Copy link
Member

The primary reasoning I had for opening this issue and this PR is that it would be ideal if we could make the implementation of those two webservers an official spec, since it is providing value to existing applications. By making it required/official,

That's a totally reasonable position. However, at best, I feel this can be optional feature with a standard interface, and I don't think there is any point to introduce it in isolation.

I would say that the value for me would be, defining a standard (optional) interface in Rack would allow us to update Rack::Events to take advantage of this interface. The resulting performance or robustness improvement should be testable and then servers that opt into that provide that advantage.

The counter point is, if we can't show a significant advantage, I'd prefer we don't introduce such a feature, because every optional feature or alternative way of doing things adds an extra dimension of complexity to applications and server implementations.

Things like Rack::Event callbacks not firing at the right time (e.g. keeping the request open) seem like a bug of the implementation, not the interface. I'd suggest those should be fixed independently of this proposal (and would welcome such fixes).

Regarding handling errors, it can be useful to know if the request was sent to the client completely or not. One simple use case would be logging the status of the response (sent successfully or not), another would be detecting unusual client behaviour (e.g. rack-attack), if you are streaming something from upstream (e.g. a HTTP/2 client), knowing how to close the stream (with an error or not) can be important. If it's easy to implement, I'd recommend we do it.

@jeremyevans
Copy link
Contributor

From the long discussion we had with @tenderlove, it is obvious both he and I are in favor of including rack.after_reply or similar as an optional feature in the Rack 3 SPEC. Since Puma and Unicorn already support that name, I recommend we keep it. @tenderlove mentioned he liked the name rack.response_finished, so I'd like him to reply here and let us know if he is OK with rack.after_reply (the de facto standard), or whether he would like to switch to rack.response_finished.

If we keep rack.after_reply, we should keep the same API standardized by Puma and Unicorn, not calling the block with any arguments. If we switch to rack.response_finished, then we can consider passing arguments to the block, such as env, the response array (or just the status and headers), and a possible exception.

The question at this point is not whether this feature will be in Rack 3, but what key will be used for it, and if switching to rack.response_finished, what arguments will be passed to the callbacks.

@ioquatix
Copy link
Member

ioquatix commented Aug 13, 2022

If we introduce an optional interface, but it's impossible to achieve the same behaviour with non-optional interfaces (i.e. BodyProxy), we haven't introduced an optional feature, we've bifurcated the specification and server implementations as applications/frameworks will have no choice but to either 1/ require the optional feature to function (no equivalent non-optional feature) or not use it at all. That means servers which don't support the optional feature won't be able to support some frameworks [correctly]. I'm completely against any approach that leads to that outcome for what is positioned as "optional".

An example of this would be GitHub's own application which apparently depends on this optional interface. Therefore, any server which does not implement it cannot host GitHub's application, because there is no abstraction which allows them to use "either rack.after_reply if it exists or BodyProxy".

That's why my initial assessment was that this kind of feature should be non-optional, but I'm no longer of the opinion that this feature actually solves any real problem and instead makes things more complicated. I welcome someone showing some behaviour with this hook that cannot be achieved with BodyProxy (assuming middleware correctly follows the SPEC). Of course if we add extra arguments, like error handling which isn't currently available through any channel in the SPEC, that's a different situation entirely. (If we are careful, $! can also serve this purpose in a generic sense).

@jeremyevans
Copy link
Contributor

My understanding is both @tenderlove and me are OK with it being optional or required. So if you would prefer it be required and not optional, we can make it required.

@tenderlove
Copy link
Member

An example of this would be GitHub's own application which apparently depends on this optional interface. Therefore, any server which does not implement it cannot host GitHub's application, because there is no abstraction which allows them to use "either rack.after_reply if it exists or BodyProxy".

GitHub's own application also isn't thread safe or fiber safe, meaning any webserver that uses either of those models can't host GitHub's application out of the box. In the Rack project, we should add specifications that are generally useful for users, and can be implemented by webservers. Whether a webserver chooses to implement an optional spec or not is up to them. But if users find that particular feature (be it threads, fibers, processes, or an after_reply callback) to be useful, it makes sense for them to choose a webserver that implements that feature.

That means servers which don't support the optional feature won't be able to support some frameworks [correctly]. I'm completely against any approach that leads to that outcome for what is positioned as "optional".

Making this feature required makes sense to me from a webserver compatibility perspective.

If we keep rack.after_reply, we should keep the same API standardized by Puma and Unicorn, not calling the block with any arguments. If we switch to rack.response_finished, then we can consider passing arguments to the block, such as env, the response array (or just the status and headers), and a possible exception.

I think we should keep the new name and pass env, the response status and headers, and a possible exception. I like the idea of keeping the old name / interface so that nobody has to change their code, but that API was unofficial, adding the additional information makes sense, and if we're updating the spec we should just add this nice stuff.

@BlakeWilliams sorry to keep dragging on with this, but could you update the spec such that the callbacks take 4 params, env, status, headers, and error? The webserver should pass 4 parameters, but error will usually just be nil.

@ioquatix
Copy link
Member

ioquatix commented Aug 14, 2022

I would personally like to see Rack::Events adopt whatever hook we provide, and I don't think that's unreasonable, since we should probably make it easy for users to consume optional features with a fallback to the minimum level of compatibility.

I'm also not sure about retaining env and headers. When handling 1000s of long running requests (or in my case, 1 million), it would be nice to avoid having to retain env and headers as these can be a significant source of memory usage/garbage. I get there is a performance trade of for allocating a closure to capture this state, but it also forces the server to non-optimally retain those values. Here is a rough example of the problem:

begin
  response_finished = env['rack.response_finished']
  status, headers, body = app.call(env)
  # Assuming HTTP/1:
  write_status(status)
  write_headers(headers)
  if response_finished.empty?
    env = headers = nil # Allow the GC to free up resources.
  end
  write_body(body) # This might be a websocket that lasts for several hours and there might be several thousands of them.
rescue => error
ensure
  invoke_callbacks(response_finished, env, status, headers, error)
end

For this reason alone, I wouldn't support it in Falcon if I couldn't figure out a way to release those object as early as possible. I like the general idea, but what are the use cases for env and headers, which would be the two worst offenders. In the case of GitHub's application and Rails existing usage, I don't see any advantage.

If we can't solve this problem, I'm completely against this being a required feature as it limits the number of in-flight connections we can handle.

@BlakeWilliams
Copy link
Author

@BlakeWilliams sorry to keep dragging on with this, but could you update the spec such that the callbacks take 4 params, env, status, headers, and error? The webserver should pass 4 parameters, but error will usually just be nil.

No worries! Happy to update this to match that signature. I do wonder if would make sense to avoid passing headers directly and guide folks towards putting any relevant values into env though.

it would be nice to avoid having to retain env and headers as these can be a significant source of memory usage/garbage.

Is there a benchmark that could measure how much extra memory is in-use to quantify the impact? I'd be curious to know what the baseline usage is vs the usage when headers and env are retained for each request.

@ioquatix
Copy link
Member

ioquatix commented Aug 15, 2022

@BlakeWilliams yes I benchmarked this extensively and it's a non-trivial amount of data per request. I don't have the benchmarks in front of me but I'll endeavour to share this with you later this week.

To give you some rough numbers (my memory is not great because it was like 2-3 years ago I did this), it was something like 20-30GiB of memory per client and server handling 1 million connections (so about 40-50GiB in total), and IIRC it was on the order of 50-100 objects per connection... so it doesn't take much to, say double the amount of memory used. An env with 20-30 string instances, a response headers with another 5-10 objects... it all adds up.

By the way, if you do the math, 20 gibibytes / 1 000 000 = ~20 kilobytes per connection, and that's including one fiber per client and one fiber per server connection... that's at least two pages (4kb * 2) + some heap storage.

@jeremyevans
Copy link
Contributor

@BlakeWilliams sorry to keep dragging on with this, but could you update the spec such that the callbacks take 4 params, env, status, headers, and error? The webserver should pass 4 parameters, but error will usually just be nil.

No worries! Happy to update this to match that signature. I do wonder if would make sense to avoid passing headers directly and guide folks towards putting any relevant values into env though.

One usage for such a callback is for writing an access log, which will typically include the Content-Length header. I don't think we should push people to duplicate response header values in env.

it would be nice to avoid having to retain env and headers as these can be a significant source of memory usage/garbage.

Is there a benchmark that could measure how much extra memory is in-use to quantify the impact? I'd be curious to know what the baseline usage is vs the usage when headers and env are retained for each request.

If we do want to benchmark this, we should pick a benchmark that aims to reflect typical production workloads, such as Railsbench.

Note that if the callback doesn't receive env and headers, in a lot of cases, a per-request proc will be allocated, which would likely result in the implicit retention of env, headers (due to them being reachable in some way from the scope of the proc), and likely additional objects, and add more overhead due to the proc allocation. So the idea that passing these variables to the callback will necessarily increase object retention and memory usage seems dubious to me. There are probably cases were it would result in additional retention, but I doubt it would for most production uses. There are definitely cases where passing env and headers as arguments would decrease both object retention and memory usage, by avoiding the creation of per-request procs.

@ioquatix
Copy link
Member

ioquatix commented Aug 15, 2022

If this feature is not required, thus by implication not required on every request, if I see that the request likely to be long lived, I can avoid providing the callback array and avoid the memory costs, it's acceptable. I did the same thing for rewindability in Rack 2.x - I sniffed content type and only did it on requests where it was going to be used to avoid the overhead on every request.

I think you'll have a hard time showing a performance impact on something like Rails, because it's lack of scalability inherent in the design, e.g. puma only handling 8-16 simultaneous requests. Falcon is designed to handle 10,000 or more where the impact is felt more heavily. My long term goal for Falcon is 100,000 active WebSockets per process. So, based on a napkin math, you'd see an overhead of about 1-2 GiB to keep these fields around, on a total expected memory usage of 2-4GiB, so it is about a 50% memory overhead per request... this is a total stab in the dark and I'll try to come back with actual numbers once I introduce a prototype of this callback.

So, introducing this feature as optional sets the bar much lower and is probably more acceptable since server can opt in depending on the situation it expects.

I'll also add that not all servers terminate the HTTP/1 socket with the rack request, i.e. in Falcon, because it supports different underlying protocols, it has different adapters and it's own internal middleware. env and headers don't make any sense beyond the internal "rack adapter" and so would have to be added as a "close" hook. Of course this is my internal implementation detail, but it does mean "rack semantics" in a way are transported all the way down to the protocol layer... I don't know what that looks like practically speaking, but what it probably means is that there will be at least one (or more probably) allocations anyway to capture all the required state and callbacks, so whether it's a proc in the Rack application, or a layer in Falcon capturing the state, the allocation of one closure shouldn't be a huge impact except when it retains a whole heap of state (and I really like the idea of proc{}.isolate that Koichi was trying to introduce which reduces dependency on outside closures). IOW, I don't think a proc allocation is a huge overhead, and as you've suggested, I think we should benchmark that.

@ioquatix
Copy link
Member

Okay, this was merged in #1952.

@ioquatix ioquatix closed this Aug 26, 2022
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.

None yet

5 participants