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

feature: add onBodyChunkSent callback to dispatch #847

Merged
merged 3 commits into from
Jul 18, 2021

Conversation

Linkgoron
Copy link
Member

@Linkgoron Linkgoron commented Jun 20, 2021

Add a callback that's called when a body chunk is sent to the server to dispatch.

The callback is invoked only after stream.write calls the end callback (I wasn't sure if it should be called synchronously with the call to write or not).
For stream bodies, the callback will get invoked after every chunk is written, and for other types (buffer etc.) it will be invoked once after the whole body is sent.

refs: #820

@codecov-commenter
Copy link

codecov-commenter commented Jun 20, 2021

Codecov Report

Merging #847 (141528c) into main (3b66905) will decrease coverage by 0.09%.
The diff coverage is 80.00%.

Impacted file tree graph

@@             Coverage Diff             @@
##              main     #847      +/-   ##
===========================================
- Coverage   100.00%   99.90%   -0.10%     
===========================================
  Files           26       26              
  Lines         2106     2116      +10     
===========================================
+ Hits          2106     2114       +8     
- Misses           0        2       +2     
Impacted Files Coverage Δ
lib/handler/redirect.js 98.24% <50.00%> (-1.76%) ⬇️
lib/core/request.js 99.11% <83.33%> (-0.89%) ⬇️
lib/client.js 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3b66905...141528c. Read the comment docs.

lib/client.js Outdated Show resolved Hide resolved
@mcollina
Copy link
Member

@szmarczak ptal, would this do?

@szmarczak

This comment has been minimized.

@@ -207,6 +207,7 @@ Returns: `void`
* **onHeaders** `(statusCode: number, headers: Buffer[], resume: () => void) => boolean` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests.
* **onData** `(chunk: Buffer) => boolean` - Invoked when response payload data is received. Not required for `upgrade` requests.
* **onComplete** `(trailers: Buffer[]) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests.
* **onBodyChunkSent** `(chunkSize: number, totalSent: number) => void` - Invoked when a body chunk is sent to the server. Not required. For a stream body this will be invoked for every chunk. For other body types, it will be invoked once after the body is sent.
Copy link
Member

Choose a reason for hiding this comment

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

For other body types, it will be invoked once after the body is sent.

So this doesn't exactly fix the issue. It will be called only once when passing a Buffer...

Copy link
Member

Choose a reason for hiding this comment

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

Because we pass through the buffer to the inner layers without doing any splitting ourselves.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, right, it's okay :)

lib/client.js Outdated
@@ -1397,7 +1397,10 @@ function write (client, request) {

socket.cork()
socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'ascii')
socket.write(body)
const endCallback = request.onBodyChunkSent
? request.onBodyChunkSent.bind(request, contentLength, contentLength)
Copy link
Member

Choose a reason for hiding this comment

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

Is bind really necessary in this case?

Copy link
Member Author

Choose a reason for hiding this comment

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

I could create an arrow function instead, if it makes a difference.

@Linkgoron
Copy link
Member Author

Also, I think that I still need to update d.ts files as well.

Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

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

lgtm

(we shuld do a round of benchmark before/after)

@mcollina
Copy link
Member

wdyt @ronag?

Copy link
Member

@ronag ronag left a comment

Choose a reason for hiding this comment

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

This doesn't actually solve #820 though... it's not really needed if you are passing a stream as body and if passing a buffer it has the same problem. You would have to manually split the buffer into smaller writes for this to solve the referenced issue. So I don't think this actually solves anything...

@ronag
Copy link
Member

ronag commented Jun 23, 2021

I would prefer something like:

onBodySent(chunk: Buffer)

@Linkgoron
Copy link
Member Author

This doesn't actually solve #820 though... it's not really needed if you are passing a stream as body and if passing a buffer it has the same problem. You would have to manually split the buffer into smaller writes for this to solve the referenced issue.

Yes, it doesn't solve #820. I think that this feature would probably be more useful once iterator (async or regular) bodies are supported, and once they're supported I assume that creating chunks using an iterator would be more performant than with a stream.

@ronag
Copy link
Member

ronag commented Jun 23, 2021

This doesn't actually solve #820 though... it's not really needed if you are passing a stream as body and if passing a buffer it has the same problem. You would have to manually split the buffer into smaller writes for this to solve the referenced issue.

Yes, it doesn't solve #820. I think that this feature would probably be more useful once iterator (async or regular) bodies are supported, and once they're supported I assume that

What does it solve?

creating chunks using an iterator would be more performant than with a stream.

Not sure I follow.

lib/client.js Outdated Show resolved Hide resolved
@Linkgoron
Copy link
Member Author

This doesn't actually solve #820 though... it's not really needed if you are passing a stream as body and if passing a buffer it has the same problem. You would have to manually split the buffer into smaller writes for this to solve the referenced issue.

Yes, it doesn't solve #820. I think that this feature would probably be more useful once iterator (async or regular) bodies are supported, and once they're supported I assume that

What does it solve?

It solves getting a notification when a chunk was uploaded without adding a listener to the stream, and if/when async iterators are added this would be more useful (because you can't "simply" add a listener to the iterator).

creating chunks using an iterator would be more performant than with a stream.

Not sure I follow.

In the original issue, the main complaint was that creating chunks using a stream would cause performance/memory issues. I assumed that yielding chunks of a buffer from an iterator instead of a stream would be more performant, as it doesn't have all of the stream machinery (I'm not saying that you can't do Readable.from, just that it would be less performant).

@ronag
Copy link
Member

ronag commented Jun 23, 2021

It solves getting a notification when a chunk was uploaded without adding a listener to the stream

I don't see adding a listener to a stream as a problem.

I assumed that yielding chunks of a buffer from an iterator instead of a stream would be more performant, as it doesn't have all of the stream machiner

I don't think the difference is of significance.

The only think that makes sense here IMO is that if onBodyChunkSent is set then we split the buffer ourselves internally. But I don't think having 10MB+ buffers in memory is a good idea in general. @szmarczak what are we actually trying to solve here?

@mcollina
Copy link
Member

The only think that makes sense here IMO is that if onBodyChunkSent is set then we split the buffer ourselves internally.

I don't think so. Even if we use a stream to feed data to dispatch, there is no way to know if a given chunk was written, only if it was fed to the socket. Not sure if it makes a difference.

@ronag
Copy link
Member

ronag commented Jun 23, 2021

I don't think so. Even if we use a stream to feed data to dispatch, there is no way to know if a given chunk was written, only if it was fed to the socket. Not sure if it makes a difference.

No, but if we wait for 'drain' we would at least get a hint in regards to progress.

@ronag
Copy link
Member

ronag commented Jun 23, 2021

But yea, it kind of goes back to what are we actually solving here?

@szmarczak
Copy link
Member

szmarczak commented Jun 23, 2021

@szmarczak what are we actually trying to solve here?

Sorry I haven't explained this more extensively. I though that net's .bytesWritten is updated dynamically but it isn't. Passing a 1GB buffer will cause .bytesWritten to jump high, without any progress.

But I don't think having 10MB+ buffers in memory is a good idea in general.

Correct. But some people do this.


onBodyCunkSent is useful here too. It includes the size of the headers etc. IIRC in Got we used to use request._header but that caused some problems (it was undefined in some cases iirc).

creating chunks using an iterator would be more performant than with a stream.
Not sure I follow.

I think what @Linkgoron was trying to say that you don't need to initialize the usual stream stuff when using generators.

No, but if we wait for 'drain' we would at least get a hint in regards to progress.

Yes, although we would have to remember the size of all the queued chunks. This PR makes getting upload progress much easier. We don't have to manually provide the callbacks for stream.write.

lib/core/request.js Outdated Show resolved Hide resolved
@Linkgoron Linkgoron force-pushed the feature/add-body-chunk-sent-callback branch from be85408 to ea739b2 Compare June 29, 2021 18:51
types/dispatcher.d.ts Outdated Show resolved Hide resolved
@mcollina
Copy link
Member

mcollina commented Jul 2, 2021

But I don't think having 10MB+ buffers in memory is a good idea in general.
Correct. But some people do this.

There is not much we can help with :(. If they want progress updates on that, the user would need to segment the buffer and write it as a stream.

We could potentially do that ourselves but it's a different problem than the one addressed in this PR.

@Linkgoron
Copy link
Member Author

Linkgoron commented Jul 2, 2021

@szmarczak It's quite simple to segment the buffer without using too much memory. I assume that something like the following would work?

function* chunkify(buffer, chunkSize = 10000) {
  for (let pos = 0; pos < buffer.byteLength; pos += chunkSize) {
    yield buffer.subarray(pos, pos + chunkSize)
  }
}

Then, either use chunkify(buffer) or Readable.from(chunkify(buffer)) (if you want to add listeners to the stream) as the body

@szmarczak
Copy link
Member

I think that could work. Will check this on Sunday.

@Linkgoron
Copy link
Member Author

Looking at the CI benchmarks, they seem way too different between this branch and main (if I'm reading the results correctly, this branch is way faster, which it shouldn't be). I assume that they're not reliable/consistent enough?

@mcollina
Copy link
Member

I've checked the benchmarks on my dedicated server and there is no regressions.

@szmarczak have you had a chance to look at this?

@ronag
Copy link
Member

ronag commented Jul 10, 2021

Sorry to be difficult but I'm still unsure how this is significantly better than just attaching a 'data' handler on the body?

@szmarczak
Copy link
Member

szmarczak commented Jul 10, 2021

I've run benchmarks on my PC and got these:

main branch:

szm@solus ~/Desktop/undici $ node benchmarks/benchmark.js 
│ Tests               │ Samples │           Result │ Tolerance │ Difference with slowest │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ http - no keepalive │      15 │  6445.03 req/sec │  ± 2.71 % │                       - │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ http - keepalive    │      25 │  8256.76 req/sec │  ± 2.50 % │               + 28.11 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - pipeline   │      20 │ 11164.69 req/sec │  ± 2.40 % │               + 73.23 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - request    │      20 │ 16932.07 req/sec │  ± 2.42 % │              + 162.72 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - dispatch   │      30 │ 17782.47 req/sec │  ± 2.74 % │              + 175.91 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - stream     │      10 │ 19110.33 req/sec │  ± 0.81 % │              + 196.51 % │

this branch:

szm@solus ~/Desktop/undici $ node benchmarks/benchmark.js 
│ Tests               │ Samples │           Result │ Tolerance │ Difference with slowest │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ http - no keepalive │      20 │  7768.10 req/sec │  ± 2.66 % │                       - │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ http - keepalive    │      30 │  8190.95 req/sec │  ± 2.90 % │                + 5.44 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - pipeline   │      10 │ 13091.86 req/sec │  ± 0.94 % │               + 68.53 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - stream     │      30 │ 16587.85 req/sec │  ± 2.92 % │              + 113.54 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - request    │      15 │ 16713.28 req/sec │  ± 2.41 % │              + 115.15 % │
|─────────────────────|─────────|──────────────────|───────────|─────────────────────────|
│ undici - dispatch   │      10 │ 19770.27 req/sec │  ± 0.73 % │              + 154.51 % │

There is negligible difference.


chunkify benchmarks
diff --git a/benchmarks/server.js b/benchmarks/server.js
index 2690be7..3f04053 100644
--- a/benchmarks/server.js
+++ b/benchmarks/server.js
@@ -24,9 +24,9 @@ if (cluster.isPrimary) {
   }
 } else {
   const server = createServer((req, res) => {
-    setTimeout(function () {
+    req.resume().on('end', () => {
       res.end('hello world')
-    }, timeout)
+    });
   }).listen(port)
   server.keepAliveTimeout = 600e3
 }
diff --git a/lib/core/request.js b/lib/core/request.js
index 928f4a7..5d97e43 100644
--- a/lib/core/request.js
+++ b/lib/core/request.js
@@ -9,6 +9,12 @@ const assert = require('assert')
 
 const kHandler = Symbol('handler')
 
+function* chunkify(buffer, chunkSize = 2048) { // 2KB
+  for (let pos = 0; pos < buffer.byteLength; pos += chunkSize) {
+    yield buffer.subarray(pos, pos + chunkSize)
+  }
+}
+
 class Request {
   constructor ({
     path,
@@ -53,9 +59,9 @@ class Request {
     } else if (util.isReadable(body)) {
       this.body = body
     } else if (util.isBuffer(body)) {
-      this.body = body.length ? body : null
+      this.body = body.length ? chunkify(body) : null
     } else if (typeof body === 'string') {
-      this.body = body.length ? Buffer.from(body) : null
+      this.body = body.length ? chunkify(Buffer.from(body)) : null
     } else if (util.isIterable(body)) {
       this.body = body
     } else {
// benchmark.js
undiciOptions.body = Buffer.alloc(1024 * 1024 * 10) // 10MB

cronometro(
  {
    'chunkify' () {
      return makeParallelRequests(resolve => {
        dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve))
      })
    }
  },
  {
    iterations,
    errorThreshold,
    print: false
  },
  (err, results) => {
    if (err) {
      throw err
    }

    console.log(printResults(results))
    dispatcher.destroy()
  }
)
// │ Tests       │ Samples │         Result │ Tolerance │ Difference with slowest │
// |─────────────|─────────|────────────────|───────────|─────────────────────────|
// │ no chunkify │     101 │ 101.67 req/sec │  ± 3.07 % │                       - │

// │ Tests    │ Samples │        Result │ Tolerance │ Difference with slowest │
// |──────────|─────────|───────────────|───────────|─────────────────────────|
// │ chunkify │      10 │ 24.49 req/sec │  ± 0.12 % │                       - │

After switching from 2KB to 16KB chunks:

│ Tests    │ Samples │        Result │ Tolerance │ Difference with slowest │
|──────────|─────────|───────────────|───────────|─────────────────────────|
│ chunkify │      10 │ 73.65 req/sec │  ± 1.73 % │                       - │

It's still a 25% performance drop.

@szmarczak
Copy link
Member

szmarczak commented Jul 10, 2021

There's only 5% performance drop when using 10MB body and 64KB chunks:

│ Tests    │ Samples │        Result │ Tolerance │ Difference with slowest │
|──────────|─────────|───────────────|───────────|─────────────────────────|
│ chunkify │      60 │ 95.31 req/sec │  ± 2.96 % │                       - │

1MB body and 64KB chunks:

│ Tests    │ Samples │         Result │ Tolerance │ Difference with slowest │
|──────────|─────────|────────────────|───────────|─────────────────────────|
│ chunkify │      10 │ 840.84 req/sec │  ± 2.56 % │                       - │

│ Tests       │ Samples │         Result │ Tolerance │ Difference with slowest │
|─────────────|─────────|────────────────|───────────|─────────────────────────|
│ no chunkify │      35 │ 883.46 req/sec │  ± 2.96 % │                       - │

100KB chunks:

│ Tests    │ Samples │         Result │ Tolerance │ Difference with slowest │
|──────────|─────────|────────────────|───────────|─────────────────────────|
│ chunkify │      20 │ 837.13 req/sec │  ± 2.75 % │                       - │

100KB chunks and 1KB body:

│ Tests    │ Samples │          Result │ Tolerance │ Difference with slowest │
|──────────|─────────|─────────────────|───────────|─────────────────────────|
│ chunkify │      15 │ 4504.18 req/sec │  ± 2.78 % │                       - │

no chunkify:

│ Tests    │ Samples │          Result │ Tolerance │ Difference with slowest │
|──────────|─────────|─────────────────|───────────|─────────────────────────|
│ chunkify │      10 │ 4719.71 req/sec │  ± 2.99 % │                       - │

@szmarczak
Copy link
Member

Given these numbers, there's almost no difference if the chunks are 64KB or 100KB. It's still 5% performance drop.

So I think it would be best to include chunkify in the docs for those who want more detailed progress callbacks.

@ronag
Copy link
Member

ronag commented Jul 10, 2021

How about just adding an example in the examples folder?

@szmarczak
Copy link
Member

Sounds good.

@Linkgoron
Copy link
Member Author

Linkgoron commented Jul 10, 2021

Sorry to be difficult but I'm still unsure how this is significantly better than just attaching a 'data' handler on the body?

For iterator bodies you'd have to wrap them in a stream (or build a wrapper). Regarding the 'data' callback itself, this is a bit different as this PR calls you after the chunk was sent while 'data' calls you before the chunk was sent. I'm not sure if I would call that significant.

@ronag
Copy link
Member

ronag commented Jul 10, 2021

calls you after the chunk was sent while 'data' calls you before the chunk was sent

Yes, I just don't see how/when that would matter?

@szmarczak
Copy link
Member

I don't think this would matter. Can we get this merged? This way we don't have to wrap some bodies into streams.

@Linkgoron Linkgoron force-pushed the feature/add-body-chunk-sent-callback branch from 7cdb7ff to e506c31 Compare July 14, 2021 14:13
Copy link
Member

@ronag ronag left a comment

Choose a reason for hiding this comment

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

I'm -0 with this after comments are resolved.

lib/client.js Outdated
@@ -1490,8 +1493,11 @@ function writeStream ({ client, request, socket, contentLength, header, expectsP
}

bytesWritten += len
const endCallback = request.onBodyChunkSent
? () => request.onBodyChunkSent(chunk)
: undefined
Copy link
Member

Choose a reason for hiding this comment

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

It's probably better and faster to just call onBodyChunkSent after/before write. Using the callback doesn't really do that much, it's no guarantee that it was sent, just that it's written to the kernels buffer.

} catch (err) {
this.onError(err)
}
}
Copy link
Member

Choose a reason for hiding this comment

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

No need to lazyily do this. Just put a onBodySent method on the class.

@@ -203,6 +203,7 @@ Returns: `void`
* **onHeaders** `(statusCode: number, headers: Buffer[], resume: () => void) => boolean` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests.
* **onData** `(chunk: Buffer) => boolean` - Invoked when response payload data is received. Not required for `upgrade` requests.
* **onComplete** `(trailers: Buffer[]) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests.
* **onBodyChunkSent** `(chunk: string | Buffer | Uint8Array) => void` - Invoked when a body chunk is sent to the server. Not required. For a stream or iterable body this will be invoked for every chunk. For other body types, it will be invoked once after the body is sent.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* **onBodyChunkSent** `(chunk: string | Buffer | Uint8Array) => void` - Invoked when a body chunk is sent to the server. Not required. For a stream or iterable body this will be invoked for every chunk. For other body types, it will be invoked once after the body is sent.
* **onBodySent** `(chunk: string | Buffer | Uint8Array) => void` - Invoked when a body chunk is sent to the server. Not required. For a stream or iterable body this will be invoked for every chunk. For other body types, it will be invoked once after the body is sent.

@ronag
Copy link
Member

ronag commented Jul 16, 2021

Should we rename/alias onData to onBodyReceived?

@szmarczak
Copy link
Member

Eventually onBodyChunkSent can be just onSent or onDataSent.

@szmarczak
Copy link
Member

Should we rename/alias onData to onBodyReceived?

I'm okay with onData.

@Linkgoron Linkgoron force-pushed the feature/add-body-chunk-sent-callback branch from e506c31 to de80264 Compare July 17, 2021 17:31
@Linkgoron
Copy link
Member Author

I've pushed the naming changes and the behaviour changes. Note that now the callback is called after write is called synchronously (except in the case of async iterators, where it might happen after awaiting for drain)

@mcollina mcollina merged commit def2b77 into nodejs:main Jul 18, 2021
crysmags pushed a commit to crysmags/undici that referenced this pull request Feb 27, 2024
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.

None yet

5 participants