KAFKA-20578: Initial producer incremental allocation for uncompressed data - #22654
KAFKA-20578: Initial producer incremental allocation for uncompressed data#22654lianetm wants to merge 56 commits into
Conversation
| Importance.MEDIUM, | ||
| CommonClientConfigs.CLIENT_DNS_LOOKUP_DOC) | ||
| .define(BUFFER_MEMORY_CONFIG, Type.LONG, 32 * 1024 * 1024L, atLeast(0L), Importance.HIGH, BUFFER_MEMORY_DOC) | ||
| .define(BUFFER_MEMORY_ALLOCATION_STRATEGY_CONFIG, |
There was a problem hiding this comment.
Should we mark this as internal to prevent it from leaking into 4.4 before the feature is fully implemented?
There was a problem hiding this comment.
Yes, done, and created https://issues.apache.org/jira/browse/KAFKA-20763 to publish it once the feature is complete
| // pool memory by completing batches). On exhaustion, close the batch (making it | ||
| // drainable) and fall through to the blocking first-record path next iteration. | ||
| try { | ||
| extensionChunks = chunkedFree.allocateChunks(extensionBytes, 0L); |
There was a problem hiding this comment.
Why do we need to do a special non-blocking call? In the existing logic, if an allocation request is blocked, all existing ProducerBatches become immediately drainable.
There was a problem hiding this comment.
Not strictly needed, agreed, it was just to fail fast and avoid what seemed like a "waisted" wait here, exactly because of the fact you mention, that blocking would make all open batches drainable, including this one we want to extend (and this is the trick I was trying to avoid, updated the comment that was misleading).
So with a blocking call, even if we get the pool memory we need in time, the batch would probably be gone/drained, and we would need to start a new batch anyways (so opting for non-blocking just to go straight to the new-batch phase if pool is exhausted when extending).
That being said, there is a case where blocking here would seem better, that's if the pool is exhausted at this point but it frees-up before the batch we're extending is drained. But that one didn't seem common in practice given that the sender drains batches before polling (so by the time memory frees up from a request that compelted, the batch we're waiting to extend would be drained already). Makes sense?
| last.closeForRecordAppends(); | ||
| } | ||
| } | ||
| continue; |
There was a problem hiding this comment.
It may take a bit of time for the closed batches to be drained. If we continue here, it seems that the client will just busy-loop until some batches are drained and some free space becomes available in buffer pool?
There was a problem hiding this comment.
even without the drain the closeForAppends should be enough to make that the next iteration find that the stream is closed => isFull => !hasRoom so starting a new batch (blocking, no busy loop). Makes sense? Added a test testExhaustedExtensionFallsBackToBlockingNewBatchPath to show the behaviour.
| * them, and retries. Otherwise defers to the parent. | ||
| */ | ||
| @Override | ||
| protected RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, |
There was a problem hiding this comment.
It's a bit awkward to have a return value of null and RecordAppendResult.needsExtension. Could we introduce a non-null value to indicate the batch is full?
There was a problem hiding this comment.
Agreed, done. Introduced needsNewBatch (aligned with needsBufferExtension)
| // ProducerBatch), which can't take extension chunks. Only attach to a | ||
| // writable chunked batch; otherwise refund the chunks and re-evaluate. | ||
| if (last instanceof ChunkedProducerBatch && last.isWritable()) { | ||
| ((ChunkedProducerBatch) last).addBuffers(extensionChunks); |
There was a problem hiding this comment.
I guess two concurrent clients could add buffers exceeding the batch size? Those buffers won't be used, but can only be freed after the batch is drained.
There was a problem hiding this comment.
Correct, there could be over-reservation at this point with concurrent appends. As for how the extra buffers would be used/released, I see the following cases:
- used right away: next loop iteration -> the loser's
tryAppendright after this line fails withneedsExtension(the winner already took the free capacity that was used to calculate this extension). Then the loser loops and tries again (recomputes the extension needed, so it uses what it had added concurrently + more) - left unused, freed when batch drains (case you mentioned) -> if the loser's record can't land in this batch (batch closes and loser's record goes into new batch).
Added 2 tests to cover this: testConcurrentExtensionRaceDoesNotOverflowChunkedStream and testBatchSizeLimitRespectedDespiteOverReservation
There was a problem hiding this comment.
Here is a related case. Two concurrent producer each allocated its own extensionChunks. It's possible that the first producer's allocation is enough to fit the records from both producer. It would be useful to document that this logic optimizes for the common case, but can lead to slightly temporary over allocation.
There was a problem hiding this comment.
Yes, agreed, added a comment describing it.
| long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize; | ||
| if (remainingBytes > 0) { | ||
| // remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe. | ||
| freeUp((int) remainingBytes); |
There was a problem hiding this comment.
This is a no-op since all pooled chunks have been used if we reach here.
| pooled.add(free.pollFirst()); | ||
| long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize; | ||
| if (remainingBytes > 0) { | ||
| // remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe. |
There was a problem hiding this comment.
Why is remainingBytes guaranteed to be an int? memoryRequired could be larger than int and pooled.size() initially could be 0.
There was a problem hiding this comment.
yup, good catch, seemed safe here but it's not because of how memoryRequired is rounded up higher up. This code here does not exist anymore though so no changes (the other place where a similar unsafe op was being done to calculate stillNeeded went away too, removed with the comment/fix below)
| } | ||
| long stillNeeded = memoryRequired - (long) pooled.size() * chunkSize - accumulated; | ||
| if (stillNeeded > 0) { | ||
| freeUp((int) stillNeeded); |
There was a problem hiding this comment.
This may be ok, but it's a bit weird to free up the chunks only to be reallocated again. Here is an alternative that doesn't require a freeup() call.
// Reuse pooled chunks first. If a reused chunk covers a slot we already reserved as
// raw bytes in an earlier iteration, hand that raw reservation back to the pool.
while (pooled.size() < numChunks && !free.isEmpty()) {
pooled.add(free.pollFirst());
if (accumulated >= chunkSize) { // accumulated is always chunk-aligned here
accumulated -= chunkSize;
this.nonPooledAvailableMemory += chunkSize; // refund → available to other waiters
}
}
// Reserve raw memory for any still-uncovered chunks, in whole chunks.
while (pooled.size() + (int)(accumulated / chunkSize) < numChunks
&& this.nonPooledAvailableMemory >= chunkSize) {
this.nonPooledAvailableMemory -= chunkSize;
accumulated += chunkSize;
}
|
|
||
| // Take pool chunks first, then reserve non-pool bytes for the remainder. | ||
| while (pooled.size() < numChunks | ||
| && (long) (pooled.size() + 1) * chunkSize + accumulated <= memoryRequired |
There was a problem hiding this comment.
The first condition is redundant, given the second one.
(pooled+1)*chunkSize + accumulated ≤ memoryRequired
⇒ (pooled+1)*chunkSize ≤ memoryRequired − accumulated ≤ memoryRequired = numChunks*chunkSize
⇒ pooled+1 ≤ numChunks
⇒ pooled < numChunks
There was a problem hiding this comment.
Agreed (the condition changed though, with the comment above, and does not have the redundancy anymore, so no changes directly for this)
| // On failure (timeout / close / interrupt), refund the non-pool bytes taken. | ||
| // Pool chunks already in `pooled` are returned to `free` separately by the | ||
| // outer catch. | ||
| this.nonPooledAvailableMemory += accumulated; |
There was a problem hiding this comment.
Could we return accumulated and pooled chunks in the same place? For example, we can set a flag like allocationCompleted to replace accumulated = 0. Then we can free both accumulated and pooled chunks if the flag is not set.
There was a problem hiding this comment.
Done. Introduced the allocationCompleted flag and now all refunds happen together in a finally, much better indeed.
|
Thanks for the comments @junrao ! All addressed (just some changes pending the parallel refactoring, details in the answers above) |
| * @throws BufferExhaustedException if the request can't be satisfied within {@code maxTimeToBlockMs} | ||
| * @throws KafkaException if the pool is closed during the wait | ||
| */ | ||
| public List<ByteBuffer> allocateChunks(int totalSize, long maxTimeToBlockMs) throws InterruptedException { |
There was a problem hiding this comment.
Out of curiosity, have you considered moving allocateChunks into BufferPool instead of having specific ChunkedBufferPool? Also, allocate seems to be a variant of allocateChunks which would simplify the codebase.
There was a problem hiding this comment.
interesting, I moved the allocateChunks into BufferPool and it's a good simplification indeed. I also extracted the common logic that applies to allocate and allocateChunks (awaitMemory), but didn't go for a full merge of the 2 because even though similar (the common logic extracted), they have some differences and made the fully-consolidated approach tricky (e.g., the nature of what they reserve, full chunks vs bytes, metrics used differently). Makes sense?
| continue; | ||
| } | ||
| if (appendResult.needsNewBatch()) | ||
| throw new IllegalStateException("appendNewBatch must not return a needsNewBatch result"); |
There was a problem hiding this comment.
Could we do this check right after we get appendResult back?
| */ | ||
| public enum Outcome { APPENDED, NEEDS_BUFFER_EXTENSION, NEEDS_NEW_BATCH } | ||
|
|
||
| public final Outcome outcome; |
There was a problem hiding this comment.
yes, both indeed, done + comment on what the callers invoke.
| * @param nowMs The current time, in milliseconds | ||
| * @return the append result, which never has {@code needsNewBatch=true}: the method either | ||
| * propagates a non-{@code needsNewBatch} result from an open batch created concurrently | ||
| * (a success, or — incremental strategy — a {@code needsBufferExtension} signal), or it |
There was a problem hiding this comment.
This comment is not very clear to me. a success should return appended, not needsBufferExtension, right?
There was a problem hiding this comment.
yes, that was the intention on the comment: success (appended) OR needsBufferExtension. Updated it to make it clearer.
| */ | ||
| public void addBuffers(List<ByteBuffer> newChunks) { | ||
| ensureNotDeallocated(); | ||
| chunks.addAll(newChunks); |
There was a problem hiding this comment.
Should we call ensureWritable() here too?
| } | ||
| chunks.clear(); | ||
| currentChunk = null; | ||
| currentChunkIndex = 0; |
There was a problem hiding this comment.
0 is a valid index. Should we set it to -1?
|
|
||
| @Override | ||
| public void ensureRemaining(int remainingBytesRequired) { | ||
| ensureNotDeallocated(); |
There was a problem hiding this comment.
Should we call this in limit() and initialCapacity() too?
There was a problem hiding this comment.
makes sense (done + tests)
| @Override | ||
| public ByteBuffer buffer() { | ||
| ensureNotDeallocated(); | ||
| finalized = true; |
There was a problem hiding this comment.
It's probably better to set this when close() is called. We can then rename it to closed.
| // so bufferStream was allocated (this iteration or carried from a prior one). | ||
| if (bufferStream == null) | ||
| throw new IllegalStateException("needsNewBatch path reached without an allocated buffer stream"); | ||
| int firstRecordSize = AbstractRecords.estimateSizeInBytesUpperBound( |
There was a problem hiding this comment.
This code is different from the code when the new batch is allocated. Could we remember the first record size when the new batch is allocated and reuse it here?
int recordUncompressed = AbstractRecords.recordSizeUpperBound(
RecordBatch.CURRENT_MAGIC_VALUE, compression.type(), key, value, headers);
int size = MemoryRecordsBuilder.estimatedBytesWritten(
RecordBatch.CURRENT_MAGIC_VALUE, compression.type(),
CompressionRatioEstimator.estimation(topic, compression.type()),
recordUncompressed);
There was a problem hiding this comment.
yes, done (will probably need review once compression lands though, just added a TODO for now and we can discuss once there)
junrao
left a comment
There was a problem hiding this comment.
@lianetm : Thanks for the updated PR. A few more comments. Also, there is code duplicated between RecordAccumulator and ChunkedRecordAccumulator. Claude suggested the following two alternatives. Is that worth doing?
Alternative 1: Keep ChunkedRecordAccumulator as subclass.
// base RecordAccumulator (the single loop), pseudo-hooks:
// 1) allocate storage for a NEW batch, return the builder supplier (+ a handle to release on cleanup)
protected BatchBuilderHandle allocateNewBatchBuilder(topic, part, record, maxTimeToBlock) { … }
// Full: ByteBuffer via free.allocate → () -> MemoryRecords.builder(buffer, …)
// Chunked: chunks via allocateChunks → new ChunkedByteBufferOutputStream → () -> chunkedRecordsBuilder(stream, firstRecordSize)
// 2) handle the NEEDS_BUFFER_EXTENSION outcome (allocate off-lock, re-lock, attach, retry)
protected void extendOpenBatch(dq, extensionBytesNeeded, maxTimeToBlock) { … }
// Full: unreachable — tryAppend never returns NEEDS_BUFFER_EXTENSION (fixed buffer == writeLimit)
// Chunked: allocateChunks(0L) → addBuffers → retry / close-and-fall-through on exhaustion
The single loop then does:
while (true) {
... pick partition, get dq ...
synchronized (dq) {
if (partitionChanged(...)) continue;
RecordAppendResult r = tryAppend(...);
switch (r.outcome) {
case APPENDED: updatePartitionInfo(...); return r;
case NEEDS_BUFFER_EXTENSION: /* release lock */ extendOpenBatch(...); continue;
case NEEDS_NEW_BATCH: ... allocateNewBatchBuilder(...) → appendNewBatch(...) ...
}
}
}
Alternative 2: Composition over inheritance (cleaner, bigger change): instead of ChunkedRecordAccumulator extends RecordAccumulator, inject a
BatchAllocationStrategy (Full vs Chunked) into a single RecordAccumulator. One accumulator, one loop, strategy object handles storage + extension.
| * @throws BufferExhaustedException if the request can't be satisfied within {@code maxTimeToBlockMs} | ||
| * @throws KafkaException if the pool is closed during the wait | ||
| */ | ||
| public List<ByteBuffer> allocateChunks(int totalSize, long maxTimeToBlockMs) throws InterruptedException { |
There was a problem hiding this comment.
I guess the caller is expected to use either allocate() or allocateChunks(). Should we prevent the caller from mixing the two calls?
There was a problem hiding this comment.
yes. I added an allocation mode to the pool, then we can easily enforce not only that allocate and allocateChunks are not mixed, but also that they are used with the right intention. Thoughts?
| * resources like compression buffers. The batch will be fully closed (ie. the record batch headers will be written | ||
| * and memory records built) in one of the following cases (whichever comes first): right before send, | ||
| * if it is expired, or when the producer is closed. | ||
| * If it is full (or absent), we return {@link RecordAppendResult#NEEDS_NEW_BATCH} and a new batch is created. |
There was a problem hiding this comment.
It would be useful to document what types of RecordAppendResult this method could return.
There was a problem hiding this comment.
sure, done (here and on the chunk override)
| * @return the append result, which is never {@code needsNewBatch}. It is either {@code appended} | ||
| * — the record was appended, whether to a batch another thread created concurrently or to | ||
| * the batch this method creates — or, in the incremental strategy, {@code needsBufferExtension}: | ||
| * a concurrent appender created an extendable open batch, so the caller releases its |
There was a problem hiding this comment.
a concurrent appender created an extendable open batch => a concurrent appender created an extendable open batch, but the new record doesn't fit in.
| throw e; | ||
| } | ||
| nowMs = time.milliseconds(); | ||
| bufferStream = new ChunkedByteBufferOutputStream(initialChunks, chunkedFree.poolableSize(), chunkedFree); |
There was a problem hiding this comment.
Could we put newBatchSize and bufferStream in a record since they should always be present together?
There was a problem hiding this comment.
sure, makes sense (just that it cannot be in a record, java 11, but consolidated them with a class)
| * Unlike the single-buffer batch — which must donate a fresh buffer because the network | ||
| * layer may still be reading the pooled one — a chunked batch's inflight bytes live in the | ||
| * separate flattened buffer (see {@link ChunkedByteBufferOutputStream#buffer()}), so it is | ||
| * safe to return the actual chunks to the pool here. |
There was a problem hiding this comment.
a chunked batch's inflight bytes live in the separate flattened buffer
This is true for now, but won't be true when we avoid flattening the buffer. Should we add a TODO to remind ourselves?
| * appender racing the same batch. | ||
| */ | ||
| private BufferPool poolMockingConcurrentChunkAllocation(int chunkSize, long totalMemory, | ||
| AtomicReference<ChunkedRecordAccumulator> injectAppendOnce, |
| // The original batch is far below its writeLimit, so isFull() can only be true via | ||
| // the closed append stream — i.e., it was closed for appends on the failed extension. | ||
| assertTrue(dq.peekFirst().isFull(), "original batch must be closed for appends"); | ||
| assertNotNull(dq.peekFirst()); |
There was a problem hiding this comment.
Should we do this one before the previous line?
There was a problem hiding this comment.
sure, done (it was a dup I introduced to fix a warning from the IDE). Fixed a similar one too.
|
|
||
| // Second record overflows the batch's chunk, so extension attempt fails (pool empty, | ||
| // recovered by closing the batch), then the new-batch acquire fails too (maxTimeToBlock | ||
| // = 0). The two failed acquires must count as a single dropped record. |
There was a problem hiding this comment.
The new batch acquire should have maxTimeToBlock != 0, right?
There was a problem hiding this comment.
yes, right. Updated. The extension is the only one non-blocking in this case, then the new batch acquire is done with the maxTimeToBlock != 0
| * Test that chunks are returned to the pool only at batch completion (deallocate), never at close. | ||
| */ | ||
| @Test | ||
| public void testBatchCloseDoesNotDeallocateChunksPrematurely() throws Exception { |
There was a problem hiding this comment.
Where does it test the deallocation doesn't happen?
There was a problem hiding this comment.
good catch, it was checking the records were still readable, but not directly checking the memory, fixed
| assertNotNull(batch); | ||
| assertEquals(6, batch.recordCount); | ||
|
|
||
| // batch.close() flattens chunks and writes the header. If chunks under-allocated, this |
There was a problem hiding this comment.
Hmm, if chunks are under allocated, we should throw at append time, not close time, right?
There was a problem hiding this comment.
yes, updated the comment (assertions were already aligned). No throw at close here, under-allocation would be an IllegalState on tryAppend.
|
Thanks for the review @junrao , all comments addressed. About the high level one (this) on the overlap between RecordAccumulator and ChunkedRecordAccumulator, totally agree. I refactored to extract and reuse different common bits for now (keeping both classes still, just common helpers, along the lines of the alternative 1). Compression will touch the core of this, so wonder if better to wait to see how those changes fit here, to see how much common surface we still have and then decide if continue with the helpers approach, taking them a step further, or go with some like the alt 2 approach. Makes sense? if so I would create a jira on me to review/dedup along with the compression changes. |
| * strategy), {@link #CHUNKED} only {@link #allocateChunks} (the incremental strategy). Fixed at | ||
| * construction so the two are never mixed on the same pool. | ||
| */ | ||
| public enum AllocationMode { SINGLE, CHUNKED } |
There was a problem hiding this comment.
Would it better to use FULL and INCREMENTAL?
| throw new IllegalStateException("needsNewBatch path reached without an allocated buffer stream"); | ||
| // Reuse the new-batch size estimate as the write-limit basis. | ||
| // TODO: review when compression is supported. | ||
| final NewBatchBuffer pending = newBatch; |
There was a problem hiding this comment.
pending => pendingNewBatch?
| */ | ||
| private static final class NewBatchBuffer { | ||
| final ChunkedByteBufferOutputStream stream; | ||
| final int size; |
|
|
||
| // Now free chunks one at a time, allowing the multi-chunk request to accumulate. | ||
| p.deallocate(h1); | ||
| Thread.sleep(50); |
There was a problem hiding this comment.
Why does it need to sleep for 50ms, which is long for a unit test? Ditto below.
There was a problem hiding this comment.
yeap not needed in this case really, and the other one can be better done with a waitForCondition on the available memory, done.
| } catch (Throwable th) { | ||
| err.set(th); | ||
| } | ||
| }, "slow-success"); |
There was a problem hiding this comment.
slow-success => chunked-waiter ?
|
Thanks @junrao ! All comments addressed. |
| // Use the chunked path only when a batch is at least one full chunk | ||
| // (batch.size >= CHUNK_SIZE). Below that, a batch can't fill even one chunk, so chunking | ||
| // would over-reserve and the producer falls back to the full strategy instead. | ||
| boolean useIncremental = incremental && batchSize >= ChunkedRecordAccumulator.CHUNK_SIZE; |
There was a problem hiding this comment.
If we switch automatically to full because of the batch size, it might be useful to log a warning.
| // A single call can guarantee at most `chunkSize` of space (the stream advances one chunk | ||
| // at a time). Callers needing more attach chunks via addBuffers first. write(byte[]) loops | ||
| // across chunks, so contiguous capacity isn't required. | ||
| ensureChunkCapacity(Math.min(remainingBytesRequired, chunkSize)); |
There was a problem hiding this comment.
Hmm, this still seems to only work if remainingBytesRequired is 1. If it's larger than 1 and the current chunk doesn't have enough remaining bytes, we move to the next chunk. A subsequent write will write on the new chunk, the remaining bytes on the previous chunk are wasted.
| /** | ||
| * Appends the record after checking there's chunk capacity for it. | ||
| * At this point it's expected that the allocated chunks have | ||
| * capacity for the record because the accumulator never routes an |
There was a problem hiding this comment.
At this point it's expected that the allocated chunks have capacity
Hmm, is this true? This method could return null because the batch is full, right?
| /** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize. */ | ||
| private long nonPooledAvailableMemory; | ||
| /** Lock held for any read or write of {@link #free}, {@link #waiters}, {@link #nonPooledAvailableMemory}, or {@link #closed}. */ | ||
| protected final ReentrantLock lock; |
There was a problem hiding this comment.
Does this still need to be protected? Ditto for other fields. We can also remove the addition of BufferPool to spotbugs-exclude.xml.
| * Wake the longest-waiting thread if any memory (pooled or non-pooled) is available. | ||
| * Must be called with {@link #lock} held. No-op if no waiters or no memory is free. | ||
| */ | ||
| protected void signalNextWaiterIfMemoryAvailable() { |
| * full strategy (allocate) and the incremental strategy (ChunkedRecordAccumulator), | ||
| * so both update the same buffer-exhausted metrics. | ||
| */ | ||
| protected void recordBufferExhausted() { |
There was a problem hiding this comment.
This can have package level visibility.
| * waiter. Acquires {@link #lock} internally. Used by callers | ||
| * that reserve memory and then need to roll back the reservation (e.g., upon errors). | ||
| */ | ||
| protected void releaseReservedBytes(long bytes) { |
| * buffers (if needed). Must be called with {@link #lock} held. | ||
| */ | ||
| private void freeUp(int size) { | ||
| protected void freeUp(int size) { |
| public class ChunkedProducerBatch extends ProducerBatch { | ||
|
|
||
| public ChunkedProducerBatch(TopicPartition tp, MemoryRecordsBuilder recordsBuilder, long createdMs) { | ||
| super(tp, recordsBuilder, createdMs); |
There was a problem hiding this comment.
Should we validate that recordsBuilder.bufferStream() is chunked?
| newBatchSize, chunkedFree.poolableSize(), topic, effectivePartition, maxTimeToBlock); | ||
| List<ByteBuffer> initialChunks; | ||
| try { | ||
| initialChunks = chunkedFree.allocateChunks(newBatchSize, maxTimeToBlock); |
There was a problem hiding this comment.
It seems that we can block on the bufferpool longer than maxTimeToBlock. Claude suggested the following path that doesn't exist in RecordAccumulator.
Iteration A: needsNewBatch → blocking allocateChunks(_, maxTimeToBlock) succeeds after a wait. A concurrent thread created a batch meanwhile, so appendNewBatch's in-lock tryAppend returns needsBufferExtension → line 257 deallocates the stream, continue.
Iteration B: extension path does the non-blocking allocateChunks(gap, 0L) → BufferExhaustedException → closes the batch (line 168) → continue.
Iteration C: batch now closed → needsNewBatch → blocks again for a fresh full maxTimeToBlock.
| throw new IllegalArgumentException("totalSize must be positive: " + totalSize); | ||
|
|
||
| int chunkSize = poolableSize(); | ||
| int numChunks = (int) (((long) totalSize + chunkSize - 1L) / chunkSize); |
There was a problem hiding this comment.
int numChunks = (totalSize - 1) / chunkSize + 1; is overflow-safe even without the cast. The existing code is correct, so feel free to ignore this comment.
Initial partial implementation for KIP-1332 (producer incremental
allocation strategy).
This PR includes:
uncompressed data only (no growth support), extra-copy on send (linked
chunks are flattened into a new buffer)
tests with the new strategy + new ones)
Support for compressed data and network layer improvements will come in
follow-up PRs.
Reviewers: Jun Rao junrao@gmail.com, Ken Huang s7133700@gmail.com,
Chia-Ping Tsai chia7712@gmail.com