Skip to content

Commit 488e60c

Browse files
committed
CCBC-1685: add lcb_trim_memory() to release cached pool memory
libcouchbase's per-pipeline netbuf allocator keeps released blocks on an available list so the fast path can re-reserve without going back to malloc. A long-lived instance that occasionally bursts and then idles retains the peak working set of every pipeline until lcb_destroy() tears the instance down. In memory-constrained containers (Kubernetes pods, tight cgroup limits) this plateau can be mistaken for a leak and eventually trigger the OOM killer once multiple instances are stacked in the same process. Add lcb_trim_memory(lcb_INSTANCE *), marked @UnCommitted. Calling it walks every pipeline's nbmgr and reqpool and frees the backing buffers of blocks on the avail list. Active (in-flight) blocks are not touched, connections are not closed, no network I/O is issued. Intended use is a periodic call from the application's own idle tick when RSS approaches a configured limit; on a busy instance the call is a no-op since freed blocks would be reallocated on the next burst. Exposed as a dedicated API rather than an lcb_cntl because lcb_cntl is reachable through the connection string, which is the wrong surface for an operational command. A standalone function also leaves room to evolve the return type (e.g. bytes released) without another ABI change. Implementation in two layers: - netbuf_shrink(nb_MGR *) in src/netbuf/netbuf.c walks each pool's avail list, frees the backing buffer of every block, and either frees the block header (standalone) or resets the cache-slot header so alloc_new_block sees it as free. Returns bytes released. - lcb_trim_memory() in src/instance.cc iterates every pipeline owned by the instance and calls netbuf_shrink on nbmgr and reqpool. Regression coverage in tests/basic/t_netbuf.cc: - shrinkFreesAvailBlocksAfterBurst drives 40 concurrent 20 KB reservations, releases them in reverse order to populate avail, and asserts that shrink empties the list and returns the expected byte count. - shrinkLeavesActiveBlocksAlone holds a reservation, forces a transient large allocation onto avail, calls shrink, and verifies the held span's buffer is still readable/writable. - shrinkOnCleanPoolIsNoop calls shrink on a freshly-initialized manager and on a cleanly-drained manager and asserts no state damage. All 96 nonio-tests continue to pass. End-to-end smoke test against a live cluster (1000 KV stores with 1.5 KB values): invoking lcb_trim_memory() followed by malloc_trim(0) releases ~340 kB of RSS on a small run; the amount scales with the peak burst size. Change-Id: I617bf964cf1329eaff989009a66a02dd57128fd2 Reviewed-on: https://review.couchbase.org/c/libcouchbase/+/243718 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Sergey Avseyev <sergey.avseyev@gmail.com>
1 parent faf66a3 commit 488e60c

5 files changed

Lines changed: 227 additions & 0 deletions

File tree

include/libcouchbase/couchbase.h

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2116,6 +2116,52 @@ LIBCOUCHBASE_API
21162116
void lcb_destroy_async(lcb_INSTANCE *instance, const void *arg);
21172117
/**@} (Group: Destroy) */
21182118

2119+
/**
2120+
* @brief Release cached pool memory back to the OS.
2121+
*
2122+
* libcouchbase services its hot path from per-pipeline buffer pools. When a
2123+
* packet is released, its backing block is parked on a "free" list inside the
2124+
* owning pool so the next reservation can be satisfied without calling
2125+
* `malloc`. The pools grow to match the peak concurrent working set and do
2126+
* not shrink: a long-lived @c lcb_INSTANCE that briefly bursts and then
2127+
* idles will retain that peak footprint until it is destroyed.
2128+
*
2129+
* Calling this function walks every pipeline owned by @p instance and frees
2130+
* the backing buffers of blocks sitting on the free list. Active blocks
2131+
* (those whose memory is currently handed out to an in-flight operation)
2132+
* are never touched, and no open server connection is closed. After the
2133+
* call, the next allocation will go back through `malloc`, so the cost of
2134+
* shrinking only pays off when the process would otherwise hold excess
2135+
* memory through a quiet period.
2136+
*
2137+
* Intended usage is periodic invocation from an application-level idle
2138+
* tick (e.g. once a minute, or after a batch completes) when the process
2139+
* runs close to a memory limit. There is no benefit to calling this on a
2140+
* busy instance — the freed blocks will be reallocated on the next burst.
2141+
* The call is synchronous and does not issue network I/O.
2142+
*
2143+
* Thread safety: this function must be called from the same thread that
2144+
* owns the I/O loop for @p instance, or with external synchronization that
2145+
* excludes all other libcouchbase calls on @p instance. It acquires no
2146+
* internal locks.
2147+
*
2148+
* @param instance the instance whose pools should be trimmed. Must be a
2149+
* valid, initialized instance (i.e. one returned by
2150+
* @ref lcb_create and not yet passed to @ref lcb_destroy).
2151+
*
2152+
* @uncommitted This function is provided to address operational scenarios
2153+
* observed with long-lived instances in memory-constrained
2154+
* containers. Its existence and signature may change in a
2155+
* future release as the underlying pool implementation
2156+
* evolves. Do not rely on it from code that must remain
2157+
* compatible across future libcouchbase versions without
2158+
* re-validation.
2159+
*
2160+
* @see lcb_destroy
2161+
*/
2162+
LIBCOUCHBASE_API
2163+
void lcb_trim_memory(lcb_INSTANCE *instance);
2164+
21192165
/** @internal */
21202166
#define LCB_DATATYPE_JSON 0x01
21212167

src/instance.cc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,25 @@ static void destroy_cb(void *arg)
859859
lcb_destroy(instance);
860860
}
861861

862+
LIBCOUCHBASE_API
863+
void lcb_trim_memory(lcb_INSTANCE *instance)
864+
{
865+
if (instance == nullptr) {
866+
return;
867+
}
868+
mc_CMDQUEUE *cq = &instance->cmdq;
869+
nb_SIZE released = 0;
870+
for (unsigned ii = 0; ii < cq->_npipelines_ex; ++ii) {
871+
mc_PIPELINE *pl = cq->pipelines[ii];
872+
if (pl == nullptr) {
873+
continue;
874+
}
875+
released += netbuf_shrink(&pl->nbmgr);
876+
released += netbuf_shrink(&pl->reqpool);
877+
}
878+
lcb_log(LOGARGS(instance, DEBUG), "lcb_trim_memory released %u bytes of idle pool memory", (unsigned)released);
879+
}
880+
862881
LIBCOUCHBASE_API
863882
void lcb_destroy_async(lcb_INSTANCE *instance, const void *arg)
864883
{

src/netbuf/netbuf.c

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,55 @@ void netbuf_cleanup(nb_MGR *mgr)
774774
mblock_cleanup(&mgr->datapool);
775775
}
776776

777+
/**
778+
* Release the backing buffers of every block in @p pool->avail.
779+
*
780+
* For cache-slot headers (those owned by @p pool->cacheblocks) the header
781+
* itself is left in place so it can be reused by @c alloc_new_block; only
782+
* the @c root buffer is freed and the block is reset to its pre-allocation
783+
* state. Standalone blocks are freed whole. In either case the block leaves
784+
* the @c avail list.
785+
*
786+
* @return bytes of backing buffer memory released
787+
*/
788+
static nb_SIZE mblock_shrink(nb_MBPOOL *pool)
789+
{
790+
sllist_iterator iter;
791+
nb_SIZE released = 0;
792+
793+
SLLIST_ITERFOR(&pool->avail, &iter)
794+
{
795+
nb_MBLOCK *block = SLLIST_ITEM(iter.cur, nb_MBLOCK, slnode);
796+
int is_standalone = mblock_is_standalone(block);
797+
nb_SIZE block_size = block->nalloc;
798+
799+
sllist_iter_remove(&pool->avail, &iter);
800+
pool->curblocks--;
801+
802+
mblock_wipe_block(block);
803+
804+
if (!is_standalone) {
805+
/* Reset the cache-slot so alloc_new_block sees it as free. */
806+
block->nalloc = 0;
807+
block->start = 0;
808+
block->cursor = 0;
809+
block->wrap = 0;
810+
}
811+
812+
released += block_size;
813+
}
814+
815+
return released;
816+
}
817+
818+
nb_SIZE netbuf_shrink(nb_MGR *mgr)
819+
{
820+
nb_SIZE released = 0;
821+
released += mblock_shrink(&mgr->datapool);
822+
released += mblock_shrink(&mgr->sendq.elempool);
823+
return released;
824+
}
825+
777826
/******************************************************************************
778827
******************************************************************************
779828
** Block Dumping **

src/netbuf/netbuf.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,22 @@ void netbuf_init(nb_MGR *mgr, const nb_SETTINGS *settings);
343343
void netbuf_cleanup(nb_MGR *mgr);
344344
void netbuf_cleanup_packet(nb_MGR *mgr, const void *packet);
345345

346+
/**
347+
* @brief Release idle memory back to the system.
348+
*
349+
* Walks every pool owned by @p mgr and frees the backing buffers of blocks
350+
* sitting in the available list (blocks not currently servicing reservations).
351+
* The active list is never touched. Cache-slot headers are kept but marked
352+
* free so they can be re-used by a subsequent @c netbuf_mblock_reserve.
353+
*
354+
* Intended for long-lived instances that occasionally burst and then idle.
355+
* See CCBC-1685.
356+
*
357+
* @param mgr the manager to shrink
358+
* @return number of bytes released
359+
*/
360+
nb_SIZE netbuf_shrink(nb_MGR *mgr);
361+
346362
/**
347363
* Populates the settings structure with the default settings. This structure
348364
* may then be modified or tuned and passed to netbuf_init()

tests/basic/t_netbuf.cc

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,100 @@ TEST_F(NetbufTest, testPacketCleanup)
592592

593593
clean_check(&mgr);
594594
}
595+
596+
/*
597+
* Regression tests for CCBC-1685.
598+
*
599+
* Long-lived lcb instances accumulate netbuf blocks on the avail list after
600+
* bursty workloads. Without a way to release them, RSS plateaus at the peak
601+
* working set until the instance is destroyed. netbuf_shrink() walks every
602+
* pool's avail list and frees the backing buffers, leaving active blocks
603+
* untouched.
604+
*/
605+
TEST_F(NetbufTest, shrinkFreesAvailBlocksAfterBurst)
606+
{
607+
nb_MGR mgr;
608+
netbuf_init(&mgr, nullptr);
609+
610+
/* Reserve enough independent blocks that multiple go on avail when
611+
* released. With the default basealloc of 32 KB, 40 spans of 20 KB each
612+
* force a new block every 2 spans. */
613+
constexpr int n_spans = 40;
614+
constexpr unsigned span_size = 20 * 1024;
615+
std::array<nb_SPAN, n_spans> spans{};
616+
for (int ii = 0; ii < n_spans; ++ii) {
617+
spans[ii].size = span_size;
618+
ASSERT_EQ(0, netbuf_mblock_reserve(&mgr, &spans[ii]));
619+
}
620+
621+
/* Release in reverse order so trailing spans flip blocks to empty
622+
* quickly and populate the avail list. */
623+
for (int ii = n_spans - 1; ii >= 0; --ii) {
624+
netbuf_mblock_release(&mgr, &spans[ii]);
625+
}
626+
627+
ASSERT_NE(0, netbuf_is_clean(&mgr));
628+
ASSERT_GT(mgr.datapool.curblocks, 0u) << "burst should have populated avail";
629+
630+
nb_SIZE before_curblocks = mgr.datapool.curblocks;
631+
nb_SIZE released = netbuf_shrink(&mgr);
632+
633+
EXPECT_GE(released, before_curblocks * span_size)
634+
<< "shrink must return at least the total nalloc of the blocks it frees";
635+
EXPECT_EQ(0u, mgr.datapool.curblocks) << "avail list must be empty after shrink";
636+
637+
/* After shrink, the pool must still be usable. */
638+
nb_SPAN span;
639+
span.size = 32;
640+
ASSERT_EQ(0, netbuf_mblock_reserve(&mgr, &span));
641+
netbuf_mblock_release(&mgr, &span);
642+
643+
clean_check(&mgr);
644+
}
645+
646+
TEST_F(NetbufTest, shrinkLeavesActiveBlocksAlone)
647+
{
648+
nb_MGR mgr;
649+
netbuf_init(&mgr, nullptr);
650+
651+
/* Reserve a span and hold onto it — active list, not avail. */
652+
nb_SPAN active;
653+
active.size = 1024;
654+
ASSERT_EQ(0, netbuf_mblock_reserve(&mgr, &active));
655+
656+
/* Cause a separate block to land on avail by allocating and releasing
657+
* a larger span. */
658+
nb_SPAN transient;
659+
transient.size = 64 * 1024;
660+
ASSERT_EQ(0, netbuf_mblock_reserve(&mgr, &transient));
661+
netbuf_mblock_release(&mgr, &transient);
662+
663+
nb_SIZE released = netbuf_shrink(&mgr);
664+
EXPECT_GT(released, 0u) << "the transient block should be reclaimed";
665+
666+
/* The active span must still be readable after shrink. */
667+
memset(SPAN_BUFFER(&active), 0xAB, active.size);
668+
char expected[1024];
669+
memset(expected, 0xAB, sizeof(expected));
670+
EXPECT_EQ(0, memcmp(SPAN_BUFFER(&active), expected, sizeof(expected)));
671+
672+
netbuf_mblock_release(&mgr, &active);
673+
clean_check(&mgr);
674+
}
675+
676+
TEST_F(NetbufTest, shrinkOnCleanPoolIsNoop)
677+
{
678+
nb_MGR mgr;
679+
netbuf_init(&mgr, nullptr);
680+
681+
EXPECT_EQ(0u, netbuf_shrink(&mgr));
682+
683+
/* Cycle a span to confirm the manager is still healthy. */
684+
nb_SPAN span;
685+
span.size = 500;
686+
ASSERT_EQ(0, netbuf_mblock_reserve(&mgr, &span));
687+
netbuf_mblock_release(&mgr, &span);
688+
EXPECT_NE(0, netbuf_is_clean(&mgr));
689+
690+
netbuf_cleanup(&mgr);
691+
}

0 commit comments

Comments
 (0)