CUBRID Thread Manager — High-Concurrency Connection/Worker Pool and Logical-Wait-Aware Concurrency Control (CBRD-26177 + CBRD-26662)
Contents:
- Theoretical Background
- Common DBMS Design
- Motivation
- CUBRID’s Approach
- CUBRID’s Approach: Phase 1 — Connection/Worker Pool Redesign (CBRD-26177)
- CUBRID’s Approach: Phase 2 — Logical-Wait-Aware Concurrency Control (CBRD-26662)
- Source Walkthrough
- Cross-check Notes
- Open Questions
- Sources
This document tracks the redesign that replaces CUBRID’s legacy
server threading — one polling thread per accepted connection plus a
max_clients-sized cubthread::worker_pool with all dispatch through
a per-pool mutex. That legacy baseline is documented separately in the
sibling doc cubrid-thread-worker-pool.md; anything there describing
“a thread per connection” or the task_group/task_worker sizing is
superseded by the design below. The work landed for the guava
release in two phases:
-
Phase 1 — Connection/Worker Pool Redesign (EPIC CBRD-26177, Resolved). Replaces the front half with a small bounded set of epoll-driven connection workers, adds a coordinator that balances connections across them and dynamically scales their count, bounds per-tick I/O via send/recv budgets, and rotates context allocation through per-worker freelists so the hot path no longer touches
new/delete. The back-end task-worker pool is retained, resized by two interim tunables (task_group,task_worker). -
Phase 2 — Logical-Wait-Aware Concurrency Control (EPIC CBRD-26662, implemented in PR #7323, open against
develop). Retires the fixedtask_group/task_workersizing in favour of an elastic worker pool whose true concurrency is bounded by per-core slots rather than by a fixed thread count. A worker that enters a logical wait (a transaction lock wait, a CSS job-queue idle, or a blocking PL call) surrenders its slot so a different worker can run in its place; the freed slot is redistributed by a 50 ms slot daemon. The two knobs becomemax_request_concurrency(active-task cap) andmax_request_worker(thread overcommit cap), both tunable at runtime.
Phase 1 is the front-end story (how bytes reach a task worker); Phase 2 is the back-end story (how many task workers may run at once, and what happens when one blocks). They share the same guava release train and the same “no perfmon on the hot path” discipline (below), so this single document carries both.
Theoretical Background
Section titled “Theoretical Background”The connection front-end of a database server has to multiplex many TCP sockets onto a finite number of CPU cores. Three architectures have dominated the literature, each with a distinct mapping between sockets, threads, and event-loop iterations.
Thread-per-connection (one-thread-per-client). Each accepted
socket gets a dedicated kernel thread that calls read()/write()
directly. The model is simple — no event-loop bookkeeping, no
demultiplexing — and is the design Stevens describes in UNIX Network
Programming, Vol. 1 (3rd ed., §16.5 “TCP Concurrent Server,
One Child per Client”) as the canonical Unix server. It scales until
the kernel’s thread-switch overhead dominates: at C10K and beyond,
the working set of stacks blows the L1/L2 caches, the scheduler’s
runqueue grows linearly with idle threads, and any shared mutex
between threads serializes the entire server. Database Internals
(Petrov, 2019, §5.3 “Concurrent Execution”) summarises the lesson:
“If you want to scale to tens of thousands of concurrent connections,
having one thread per connection becomes impractical.”
Reactor (event-driven). A small fixed pool of event-loop
threads each blocks on a multiplexer (select/poll/epoll/kqueue)
and dispatches ready sockets synchronously. The reference work is
Pai, Druschel, and Zwaenepoel, “Flash: An Efficient and Portable
Web Server” (USENIX 1999) which demonstrated that a single
asymmetric event loop using non-blocking I/O could match or beat
threaded servers at an order-of-magnitude lower memory cost. The
crucial mechanical refinement is edge-triggered epoll: with
EPOLLET, the kernel reports a readiness transition exactly once,
and the user-space loop is responsible for draining the socket until
EAGAIN. Edge-triggering eliminates wake-up storms but forces the
loop to bound how much it drains per fd — otherwise a single fat
connection can starve the others. This is the head-of-line blocking
problem inside an event-loop worker.
Proactor (asynchronous I/O). The kernel signals
completion, not readiness — Windows IOCP, Linux io_uring,
POSIX AIO. Conceptually superior for write-heavy workloads but
operationally heavier and not yet the default for database
front-ends. CUBRID’s redesign deliberately chose reactor + edge
trigger; proactor is out of scope.
Admission control via budgets. Welsh, Culler, and Brewer’s
SEDA (“SEDA: An Architecture for Well-Conditioned, Scalable
Internet Services,” SOSP 2001) framed the front-end as a sequence of
stages connected by bounded queues, with each stage applying its
own admission policy. The empirical observation is that latency
under saturation degrades far less when each stage caps the work it
will absorb in a single tick. CUBRID’s recv_budget_per_connection
and send_budget_per_connection (CBRD-26392) are the SEDA admission
gate applied to a single epoll tick: a fat reader that would happily
drain a megabyte must instead yield after 16 KB, register itself in
an “exhausted” list, and let the worker round-robin back to it on
the next iteration.
Pool sizing — Little’s law. Given an arrival rate λ
(requests/sec) and an average per-request service time S (sec), the
average number of in-flight requests is L = λ · S. A pool with
fewer than L workers will queue indefinitely; a pool with
significantly more workers wastes CPU on context switching and
blocks on internal critical sections. Database Internals (§5.3)
notes that real systems usually pick a small multiple of physical
cores and tune empirically, because S varies with the workload.
CBRD-26424 (score-based assignment) and CBRD-26636 (Worker count
sweep) implement exactly this empirical loop: measure throughput at
several task_worker sizes, pick the local maximum.
Atomic-free monitoring. Naïve performance counters use
std::atomic<uint64_t>::fetch_add per event. Under high load the
cache-line of the counter pings between cores; at hundreds of
thousands of events per second per worker the contention itself
becomes the bottleneck the counter was meant to measure. The
established workaround is thread-local accumulation with lazy
aggregation: each worker increments a private counter and the
monitor reader sums them. CBRD-26191 demonstrates the gain on YCSB
(workload-a: 58 K → 60 K ops; workload-b: 70 K → 73 K ops) by
removing only the atomic instructions on the hot path. Connection
worker statistics in this redesign follow the same rule —
statistics::metrics<> is a plain uint64_t[] per worker, summed
by the coordinator on a 1-second timer.
Bounded concurrency vs. worker count — the logical-wait problem.
Phase 1 answers how many threads should exist; Phase 2 answers a
different question — how many threads should be allowed to run at
once. The two are not the same number. Little’s law (L = λ · S)
sets the number of in-flight requests, but the service time S of a
transaction is not pure CPU: it includes logical waits — time spent
blocked on a row/page lock, on a condition variable, or on a nested
call to another server (a PL/Java stored procedure). A worker parked
in a logical wait consumes a pool slot without consuming a core.
Database System Concepts (§18 “Concurrency Control”) frames the
lock wait as the dominant non-CPU component of S under contention.
The consequence for pool sizing is sharp: if the pool is deliberately
kept small — because CBRD-26636 measured that a lean pool
(≈4–6 × cores) maximises throughput by minimising critical-section and
cache contention — then a handful of workers stuck in lock waits can
occupy the entire pool, and the server appears frozen even though every
core is idle. This is the classic thread-starvation deadlock: fewer
worker threads than the peak number of simultaneously-blocked units of
work.
The admission-control escape: decouple “active” from “resident”.
The established fix is to separate a concurrency limit (how many
units of work may be actively executing) from the thread count (how
many OS threads are resident). MySQL’s Enterprise Thread Pool is the
closest prior art: each thread group runs a stall-detection timer,
and when it detects that all of a group’s threads are blocked with work
still queued, it wakes or spawns an extra thread so the group makes
progress — capping active threads in the common case but overcommitting
when blocking demands it. SQL Server’s SOS scheduler achieves the same
end cooperatively: a worker that blocks yields its scheduler to a
runnable sibling. CUBRID Phase 2 adopts the MySQL shape but inverts the
token: instead of counting running threads, it hands each active
worker a slot, caps the slot count at max_request_concurrency, and
makes a worker return its slot when it enters a logical wait so the
slot — not a new thread — is what gets handed to the next unit of work.
Threads may overcommit up to max_request_worker, but only
max_request_concurrency of them hold a slot and run at any instant.
The slot is thus a runtime-adjustable admission gate placed exactly
at the boundary between “has CPU work to do” and “is waiting on
something logical”.
Common DBMS Design
Section titled “Common DBMS Design”The shared design space for connection front-ends has narrowed since the C10K era. Almost every modern engine sits at one of four points on the threads × event loop matrix.
PostgreSQL — process per connection. postmaster forks a
postgres backend process per accepted connection. The model gives
strong isolation (a crashing backend can be restarted without
killing peers) at the cost of high per-connection memory (≥10 MB).
The PostgreSQL community has consistently rejected proposals to
replace the model in core; instead, the project recommends external
poolers such as PgBouncer for high-concurrency workloads. There is
no equivalent of CUBRID’s “one CPU-pinned event loop per N
connections” inside PostgreSQL itself.
MySQL — thread-per-connection by default; thread pool plugin
optional. The default Connection_handler_manager runs
one-thread-per-connection, giving each TCP session a dedicated
pthread. The Enterprise Thread Pool plugin replaces this with a
fixed number of thread groups (typically equal to core count) plus
a small admission queue per group. The plugin exists exactly because
the unbounded thread-per-connection model collapses past a few
hundred concurrent sessions on the same workloads CUBRID measured in
CBRD-26152. CUBRID’s redesign moves into this same architectural
neighbourhood — bounded connection workers, group-based task
dispatch, admission via budgets — without making it a plugin.
Oracle — dedicated server vs. shared server (DRCP). The default mode is dedicated-server (process per session). Shared-server mode multiplexes many sessions onto a small pool of server processes via a dispatcher that owns the listening socket and passes requests through queues. Database Resident Connection Pooling (DRCP) generalises this so multiple application servers share the same backend pool. CUBRID’s coordinator has the same arbitration role as the Oracle dispatcher, but with finer per-worker statistics and an auto-scaling rule.
SQL Server — SOSScheduler (cooperative). SQL Server’s SOS scheduler runs a fixed number of worker threads (≈ logical core count) and switches them cooperatively at well-defined yield points inside the engine. Connections are attached to schedulers rather than owning a thread of their own. The CUBRID redesign is closer to this model than to PostgreSQL’s: connection workers are CPU-pinned, fixed in count within a min/max range, and process many sessions per loop iteration.
Where legacy CUBRID sat. Before CBRD-26177 the server ran a
polling thread per connection (each css_master_thread-spawned
session looped on its own socket) plus a cubthread::worker_pool of
size max_clients — see cubrid-thread-worker-pool.md for the
detailed walkthrough. With max_clients set to 2000 the engine
genuinely held ≥4000 threads at full saturation. Each polling thread
contended for the worker-pool’s per-core mutex on every job
dispatch; CBRD-26152 measured the result on YCSB-a as monotonically
decreasing throughput as concurrency rose, with CPU spending the
extra cycles in mutex idle rather than user code.
Where the redesign sits. With CBRD-26177 the front becomes a
small set (min_connection_worker … max_connection_worker,
defaults 4 … cores/2) of epoll-driven cubconn::connection::worker
threads each pinned to a core; the back stays a cubthread::worker_pool
sized by task_group × task_worker (renamed from
thread_core_count × the old worker count). A single
cubconn::connection::coordinator thread, also pinned, brokers
new-client placement, rebalancing, and auto-scaling. The hot path
(connection worker → task push → task worker pop) no longer takes a
shared mutex except briefly for css_conn_entry::cmutex /
rmutex, both of which are per-connection.
Handling a worker blocked on a lock (Phase 2 comparison). The four
engines above also diverge on what happens when a busy worker blocks
on a lock, which is precisely the gap CBRD-26662 closes. PostgreSQL
and Oracle dedicated-server sidestep the question — a blocked backend
is its own process, so a blocked lock-waiter costs a process, not a
shared pool slot. MySQL’s thread pool detects the stall and adds a
thread to the group. SQL Server’s SOS scheduler yields the blocked
worker’s scheduler to a runnable sibling. CUBRID Phase 2 lands between
MySQL and SQL Server: like SQL Server it lets the blocked worker’s
entitlement to run pass to another unit of work, and like MySQL it
will overcommit threads (up to max_request_worker) when blocking
demands it — but it expresses both through one artifact, the
concurrency slot, rather than through group-local thread spawning or
a cooperative scheduler. The distinguishing detail is where the
hand-off is wired: not inside the lock manager (which is left
untouched) but at the generic thread-suspension primitive in
thread_entry.cpp that the lock manager, page-buffer latch, and CSS
job queue all funnel through. The slot is surrendered for the two
reasons that dominate back-end blocking — a transaction lock wait and
a CSS job-queue idle — with the stored-procedure/PL path handled by an
explicit release: one narrow integration site rather than edits
scattered across each subsystem.
Motivation
Section titled “Motivation”Phase 1 — CPU idle under concurrency (CBRD-26152 + CBRD-26177)
Section titled “Phase 1 — CPU idle under concurrency (CBRD-26152 + CBRD-26177)”CBRD-26152 — “[Survey] 동시성 증가에 따른 CPU idle 증가 원인 조사” (“Survey of why CPU idle rises when concurrency increases”) — is the empirical study that motivated the redesign. Yechan Hong ran YCSB workload-b (read 95%, update 5%) with the client/CAS cap at 2000 and swept thread counts from 200 to 1000. The unexpected finding was quoted directly in the ticket:
“스레드의 개수가 200개에서 1000개로 증가하였지만, 오히려 iowait가 아닌 CPU idle이 증가하고 있다.” (As the thread count increased from 200 to 1000, CPU idle — not iowait — increased.)
If the bottleneck were disk, more threads would have shown up as iowait. CPU idle rising under load instead pointed at internal synchronization: threads arriving at the worker-pool dispatch mutex faster than the holder could release it, then the kernel parking them, leaving cores genuinely idle.
CBRD-26177 names two structural causes:
“각 connection 스레드들이 모두 따로 polling하고 cub_server는 이론 상 max_clients × 2 이상의 thread를 가지게 되므로 자원 및 관리 관점에서 비효율적이다.” (Each connection thread polls independently, and
cub_servertheoretically holds at leastmax_clients × 2threads, which is inefficient from both a resource and management perspective.)“동시성이 점차 높아질수록 각각이 core의 mutex를 잡고 job을 할당 받으려고 하므로 이 contention은 CPU가 idle에 있게 하는 주요 병목 지점이 된다.” (As concurrency rises, each thread contends for a core’s mutex to be assigned a job; this contention is the main bottleneck that keeps the CPU idle.)
The resulting goals were:
- Replace per-connection polling with a small bounded set of
epoll-driven connection workers — eliminate excessive
poll()calls (Acceptance Criterion 1 of CBRD-26177). - Make throughput monotonic in concurrency — additional clients should not degrade the rate (Acceptance Criterion 2).
- Add admission-style backpressure inside each worker (CBRD-26392) so a single fat connection cannot starve its peers.
- Add load-aware placement and dynamic resizing (CBRD-26406, CBRD-26407, CBRD-26424) so the engine self-tunes between idle and saturated regimes.
- Strip atomics off the monitoring hot path (CBRD-26191).
CBRD-26177 also issued a hard directive that shaped every subsequent ticket and shapes this document:
“connection worker는 매우 동시성이 높은 hot-path이므로 perfmon 계열의 모니터링 코드를 추가해서는 안된다. 심각한 성능 저하를 일으킬 수 있다.” (The connection worker is a very high-concurrency hot path, so perfmon-class monitoring code must not be added. It can cause serious performance degradation.)
This is the single most important constraint to keep in mind when
reading the source: anything that smells like a global atomic
counter or a perfmon_inc_stat() call on the worker tick is a
regression.
Phase 2 — Lock waits starve a lean worker pool (CBRD-26636 + CBRD-26662)
Section titled “Phase 2 — Lock waits starve a lean worker pool (CBRD-26636 + CBRD-26662)”Phase 1 removed the front-end bottleneck, which surfaced a second one
in the back end. CBRD-26636 — “[성능 실험] Worker 개수에 따른 성능
추이” — swept the task_worker count and found that a lean pool
(≈ 4–6 × cores) consistently beat task_worker = max_clients on
read-heavy YCSB, because each additional worker adds critical-section
entries and resource contention:
“각 Worker들은 내부적으로 잦은 Critical section 진입과 Resource contention을 가지고 있어 너무 많으면 성능 저하를 유발합니다. 반면, 너무 적은 개수로 설정되면 자원을 활용하지 못해 성능 저하를 일으키고, 잠금이나 다른 task 대기로 인해 작업이 밀려, 사용자 입장에서는 DBMS가 멈춘 것처럼 보일 수 있다.” (Each worker internally does frequent critical-section entry and resource contention, so too many degrade performance. But too few under-utilise resources, and work backs up behind lock waits or other task waits — so from the user’s point of view the DBMS looks frozen.)
CBRD-26636 names the two failure modes of a lean pool concretely:
- A single heavy task (a long-running unit of work, distinct from a long transaction) occupies a worker and shrinks the effective pool.
- Workers entering a lock wait reduce the number of running workers, so throughput collapses toward the “DB is frozen” regime.
There is a third, structural instance of the same problem in the stored-procedure path. When a PL/SP method calls a nested method, the caller’s worker blocks until the callee returns; deep nesting can park many workers on nested calls and hang the whole pool. Legacy CUBRID worked around this with a dedicated temporary worker spawned per nested call (CBRD-26683).
CBRD-26662 — “Logical-Wait-Aware Concurrency Control” — attacks all three with one idea, quoting the EPIC:
“Task worker가 논리적 대기로 인해 멈추게 되면 새 Task worker를 띄운다. 동시에 수행되는 Transaction (=Active한 Task worker)의 개수는 high_concurrency 이하여야 한다.” (When a task worker stalls on a logical wait, start a new task worker. The number of concurrently-executing transactions — active task workers — must stay at or below
high_concurrency.)
So the goal is to hold active concurrency at a lean, throughput-optimal
level while never letting a lock wait shrink the set of runnable
workers: a blocked worker yields its entitlement, a fresh or idle
worker picks up queued work, and the temporary-worker hack for SP
nesting becomes unnecessary because a nested-call wait now yields a
slot like any other logical wait. (The EPIC’s working name
high_concurrency became the parameter max_request_concurrency in
the implementation — see Phase 2 below.)
CUBRID’s Approach
Section titled “CUBRID’s Approach”The work is presented in two phases. Phase 1 (CBRD-26177) is the
front-end redesign — how bytes get from a socket to a task worker —
and is fully resolved in the guava branch. Phase 2 (CBRD-26662) is
the back-end redesign — how many task workers may run at once and
what happens when one blocks on a logical wait — and is implemented in
PR #7323, open against develop. Phase 2 keeps every front-end
mechanism of Phase 1 unchanged; it only replaces the fixed
task_group/task_worker back-end pool with an elastic, slot-bounded
one.
CUBRID’s Approach: Phase 1 — Connection/Worker Pool Redesign (CBRD-26177)
Section titled “CUBRID’s Approach: Phase 1 — Connection/Worker Pool Redesign (CBRD-26177)”Status: Resolved (guava). Front-end redesign.
Phase 1 is best understood as three figures, mirroring the diagram pages of the EPIC: the AS-IS baseline, the TO-BE state after CBRD-26212/26255, and the post-CBRD-26407 state after the coordinator is added.
Architecture diagrams
Section titled “Architecture diagrams”AS-IS (legacy). Each accepted client got a dedicated polling
thread. Each polling thread, on every iteration, would push a
task into the shared cubthread::worker_pool of size
max_clients. The push acquired a per-core mutex; with hundreds
of polling threads the mutex was contended on every dispatch.
Figure 1 — Legacy (AS-IS) architecture. N polling threads each acquire a shared per-core mutex before pushing to the task pool, causing contention that scales with active client count.
TO-BE (CBRD-26212 + CBRD-26255). A small bounded set of
connection_worker threads each runs an epoll_wait loop with
edge-triggered I/O over many client sockets. Each connection
worker is CPU-pinned. When a complete request arrives, the
connection worker calls css_push_server_task into the back-end
task pool. The number of connection workers is controlled by
min_connection_worker/max_connection_worker; the task pool is
sized by task_group × task_worker.
Figure 2 — TO-BE (CBRD-26212) architecture. A bounded set of CPU-pinned connection_worker threads each runs epoll_wait over many client sockets; completed requests are pushed via css_push_server_task into a task_group × task_worker back-end pool.
Post-CBRD-26407 (coordinator + freelist). A single
coordinator thread, pinned to core 0, owns placement
(new-client → worker), rebalancing (move existing connections
between workers when load skews), and auto-scaling
(hibernate/awaken workers within min..max). Workers send
statistics to it on a slow timer; the coordinator broadcasts
control messages back. Inside each worker, contexts are claimed
from a per-pool freelist instead of new/delete-allocated each
time.
Figure 3 — Post-CBRD-26407 coordinator architecture. A single coordinator thread pinned to core 0 owns placement, rebalancing, and auto-scaling; workers claim and retire contexts from pool::freelist; the Unix-domain controller socket allows external SCALE_UP / SCALE_DOWN / CLIENT_MOVE commands.
Connection worker (CBRD-26212)
Section titled “Connection worker (CBRD-26212)”The connection worker is implemented as cubconn::connection::worker
in connection_worker.{cpp,hpp}. It owns:
- a Linux epoll instance (
cubsocket::epoll m_events); - two file descriptors registered into that epoll: an
eventfd(m_eventfd) for inter-thread wakeups and atimerfd(m_timerfd) for periodic work (hibernation check, statistics push, HA close-all); - two per-worker message queues (
IMMEDIATE,LAZY) implemented withtbb::concurrent_queue<message>and an atomic size counter; - the live set of
context *it owns (m_context), and a deferred removal queue (m_removed_context); - two budget knobs (
m_recv_budget,m_send_budget) and an exhausted-context map (m_exhausted); - an atomic-free
statistics::metrics<statistics::worker> m_statsfor self-reporting to the coordinator.
Figure 4 — Connection worker anatomy. One CPU-pinned thread runs a cubsocket::epoll reactor; the client sockets, the inter-thread m_eventfd doorbell, and the periodic m_timerfd are all registered into it. Ready fds flow through handle_reception / handle_transmission, gated by the send/recv budgets, with over-budget fds parked in m_exhausted and re-driven on the next tick; completed requests are pushed via css_push_server_task. The worker owns its IMMEDIATE/LAZY message queues, its live and deferred-removal context sets, and its atomic-free stats.
The constructor wires the epoll, registers the eventfd/timerfd, installs three timer handlers, and spawns the worker thread:
// worker::worker — src/connection/connection_worker.cppm_recv_budget = static_cast<size_t> (prm_get_integer_value (PRM_ID_CSS_RECV_BUDGET_PER_CONNECTION));m_send_budget = static_cast<size_t> (prm_get_integer_value (PRM_ID_CSS_SEND_BUDGET_PER_CONNECTION));m_exhausted.reserve (128);
m_eventfd = eventfd (0, EFD_NONBLOCK | EFD_CLOEXEC);m_timerfd = timerfd_create (CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);// ... eventfd_register both into m_events ...
eventfd_addtimer (timer_type::HIBERNATE, timer_latency::MEDIUM_LATENCY, &worker::hibernate_check);eventfd_addtimer (timer_type::STATISTICS, timer_latency::MEDIUM_LATENCY, &worker::statistics_metrics_to_coordinator);eventfd_addtimer (timer_type::HA, timer_latency::HIGH_LATENCY, &worker::ha_close_all_connections);
m_thread = std::thread (&worker::attach, this);worker::attach is the thread entry point; it calls
initialize → run → finalize. initialize pins the thread to its
assigned core via os::resources::cpu::setaffinity (m_core),
claims a cubthread::entry, and sets the thread name to
"connections" (a name that, as we shall see, leaks into the
task pool in CBRD-26617).
The main loop is the textbook reactor:
// worker::run — src/connection/connection_worker.cppwhile (!m_stop) { nfds = m_events.wait (events.data (), events.size (), m_exhausted.empty () ? TIMEOUT_INFINITE : TIMEOUT_NOWAIT); // ... for (i = 0; i < nfds; i++) { ctx = reinterpret_cast<context *> (events[i].data.ptr); if ((events[i].events & (EPOLLHUP | EPOLLRDHUP | EPOLLERR)) && ...) { this->handle_hangup_or_error (ctx, events[i].events & EPOLLERR); continue; } if (events[i].events & EPOLLIN) { if (ctx->m_conn->fd == m_eventfd) { eventfds[0] = true; continue; } if (ctx->m_conn->fd == m_timerfd) { eventfds[1] = true; continue; } status = this->handle_reception (ctx, false); // ... } if (events[i].events & EPOLLOUT) status = this->handle_transmission (ctx, false); }
if (m_exhausted.size () > 0) handle_exhausted (); if (eventfds[0] || eventfds[1]) eventfd_handler (eventfds); }Note the timeout switch: when there are exhausted contexts to
re-drive (see Send/recv budgets below) the loop polls with
TIMEOUT_NOWAIT so it can immediately revisit them, otherwise it
blocks indefinitely on epoll_wait. The eventfd is the single
inter-thread doorbell — any outside producer (the coordinator,
another connection worker handing off, a task worker returning a
buffer) writes 1 into m_eventfd and the worker drains its
in-process queue once the loop wakes.
The connection::context (connection_context.hpp) is the
per-client object the worker owns. It contains the
css_conn_entry *m_conn, a worker index, a unique 64-bit id, a
receive state machine (HEADER → DATA → ERROR), the receiver and
transmitter, and an inline statistics::metrics<statistics::context>.
A complete request (header + optional data) is parsed inside
worker::handle_reception → handle_packet → handle_header_packet
or handle_data_packet, and the task push into the back-end pool
happens at handle_command_header_packet (when the request has no
following data) or handle_data_packet (after the data arrives):
// worker::push_task_into_worker_pool — src/connection/connection_worker.cppvoid worker::push_task_into_worker_pool (context *ctx){ /* push new task into worker pool */ css_push_server_task (*ctx->m_conn);}That single call is the entire interface between the new front
and the legacy back. css_push_server_task (in
server_support.c) wraps the connection in a css_server_task
and routes it to the cubthread worker pool with
push_task_on_core (..., conn_ref.idx, conn_ref.in_method) — the
core hash being the connection index, exactly as in the legacy
design, so a long-running session keeps affinity for the same
back-end core.
Connection lifecycle (close path) is driven by
worker::handle_connection_close. It serialises against
ctx->m_conn->cmutex, drains any in-flight task workers via
net_server_active_workers, retries (re-enqueues a
SHUTDOWN_CLIENT on the LAZY queue) if back-end workers are
still active, and on success removes the fd from epoll, marks the
context m_removed = true, and pushes it into
m_removed_context. The actual context return to the pool is
deferred to purge_stale_contexts, which sends a single
RETURN_TO_POOL message to the coordinator with the batched
list — so the freelist is touched once per loop tick, not once
per closed connection.
Connection pool (CBRD-26255)
Section titled “Connection pool (CBRD-26255)”The pool (cubconn::connection::pool in
connection_pool.{cpp,hpp}) is the owner of workers, coordinator,
and context freelist. It exists for the lifetime of the server
and is held by cub_server as a single instance.
The freelist itself is a singly-linked stack of pool::freelist
nodes, each of which embeds the actual context as its first
member so that reinterpret_cast<freelist *> (ctx) recovers the
node. The trick replaces the legacy “new context per connection”
allocation pattern:
// pool::freelist — src/connection/connection_pool.hppstruct freelist{ /* THIS MUST BE THE FIRST */ context m_context; freelist *m_next;
freelist (std::size_t capacity) : m_context (capacity), m_next (nullptr) {} ~freelist () = default;};// pool::claim_context / retire_context — src/connection/connection_pool.cppcontext *pool::claim_context (){ freelist *head; assert (m_mutex_holder == std::this_thread::get_id ());
head = m_freelist.m_head; if (head) { m_freelist.m_head = m_freelist.m_head->m_next; } else { head = new freelist (32 * 1024); } m_freelist.m_claim++;
return &head->m_context;}
void pool::retire_context (context *ctx){ freelist *head; // ... head = reinterpret_cast<freelist *> (ctx); head->m_context.reset (); if (m_freelist.m_claim > m_freelist.m_max) delete head; /* over-cap: actually free */ else { head->m_next = m_freelist.m_head; m_freelist.m_head = head; } m_freelist.m_claim--;}Figure 5 — Context freelist. Each pool::freelist node embeds the context as its first member, so reinterpret_cast<freelist *> (ctx) recovers the node with no allocation header. claim_context pops the stack head (or news a node), and retire_context resets and pushes it back — or frees it when the live count is over the cap. Both run single-threaded on the coordinator under pool::m_mutex, so contexts need no per-object atomics.
The freelist is only manipulated by code holding pool::m_mutex.
The coordinator’s handle_message_queue_new_client (which calls
claim_context) and handle_message_queue_return_to_pool (which
calls retire_context) both run on the coordinator thread, and
the coordinator holds the pool lock for its entire lifetime
(see coordinator::initialize → m_parent->lock_resource ()).
This is the design choice that makes context allocation
single-threaded without ever needing per-context atomics.
pool::initialize is wired via pool::initialize_topology, which
maps the requested max_connection_workers onto an actual NUMA
core layout via os::resources::cpu::effective () and may
additionally serialise NIC RX/TX IRQ to those cores via
os::resources::net::map_nic_to_index (cores). CBRD-26255 also
provides this NIC-pinning, which is the source of the warning
log messages discussed in the ticket comments
(warning: NIC channel configuration failed) — they are
non-fatal, surfacing only when the binary lacks CAP_NET_ADMIN
or runs in a virtualised environment.
The shutdown sequence uses a thread_watcher (a bare condvar
plus int active) to count down workers as they exit, and
pool::finalize_workers waits up to css_get_shutdown_timeout()
for m_watcher->active == 1 (only the coordinator left), then
pool::finalize_coordinator waits for active == 0. Failure
to reach those states triggers _exit(0) after a 10 s
try-lock loop in try_to_lock_resource — a deliberate hard
exit because the alternative is to wait forever for a thread
holding state nothing else can clean up.
Send/recv budgets (CBRD-26392)
Section titled “Send/recv budgets (CBRD-26392)”The budget mechanism is the single most subtle part of the
design. Without it, edge-triggered epoll plus a draining reader
would let a single client with backlog monopolise its worker:
once EPOLLIN fires, the reader is contractually obliged to
drain until EAGAIN; if the peer keeps writing, that drain
loop never returns. CBRD-26392 caps the drain per epoll tick.
Quoting the ticket directly:
“하나의 connection worker는 여러 connection들을 관리한다. 이때 하나의 긴 송수신을 수행하게 되면 다른 송수신들이 계속 blocked되며 response가 지연되게 된다. 이때 한 번에 송수신할 수 있는 양을 제한하여 전체 지연을 안정화한다.” (One connection worker manages many connections. If a single long send or receive runs, the other I/Os remain blocked and their response is delayed. Bound the amount that can be sent or received at once to stabilise the overall latency.)
Defaults: 16 KB receive, 32 KB send (see system_parameter.c).
Both can be set as low as 0 (no limit) or as high as 1 GB.
The implementation lives partly in receiver::drain /
transmitter::fill (their second argument is a size_t limit = 0
budget) and partly in
worker::handle_reception / worker::handle_transmission /
worker::handle_exhausted_add_context /
worker::handle_exhausted (connection_worker.cpp).
// worker::handle_reception — src/connection/connection_worker.cppio_status = ctx->m_recv.m_receiver.drain (ctx->m_conn->fd, m_recv_budget);if (io_status == result::PeerReset || io_status == result::Error) { /* close */ }
assert (io_status == result::Pending || io_status == result::BudgetExhausted);
if (!in_exhausted && io_status == result::BudgetExhausted) { handle_exhausted_add_context (ctx, EPOLLIN); }// worker::handle_transmission — src/connection/connection_worker.cppstatus = ctx->m_send.m_transmitter.fill (ctx->m_conn->fd, m_send_budget);// ...else if (!in_exhausted && status == result::BudgetExhausted) { handle_exhausted_add_context (ctx, EPOLLOUT); }When a context exhausts its budget, it lands in
m_exhausted keyed by context id. The main loop notices the
non-empty exhausted map and switches epoll_wait to
TIMEOUT_NOWAIT, then re-drives those contexts via
handle_exhausted after serving the current epoll batch. The
prepared flag in exhausted_context is the deferral guard:
the first time a context is added it is marked !prepared and
skipped; only on the second visit does the worker re-drain it.
This ensures every other ready fd in the current epoll batch gets
serviced before the budget-exceeded context is revisited.
The flow control finite-state machine for one fd:
stateDiagram-v2 [*] --> Idle Idle --> Reading : EPOLLIN \n handle_reception Reading --> Idle : drain Pending \n EAGAIN Reading --> Exhausted : drain BudgetExhausted \n add to m_exhausted, EPOLLIN Exhausted --> Reading : revisit on next loop \n prepared flag Idle --> Writing : EPOLLOUT \n handle_transmission Writing --> Idle : fill Ok Writing --> Exhausted : fill BudgetExhausted \n add to m_exhausted, EPOLLOUT Reading --> Closing : ClosedConnection or PeerReset Writing --> Closing : ClosedConnection or PeerReset Closing --> [*] : handle_connection_close
Figure 6 — Per-fd flow-control FSM. BudgetExhausted (voluntary yield with bytes remaining) is distinct from Pending (kernel buffer empty); an exhausted context is re-queued with a prepared flag so it is revisited in the same epoll loop iteration.
Note that result::BudgetExhausted is a distinct enum value from
result::Pending — the difference being that Pending means “the
kernel has no more bytes for me right now” (back-off naturally
until next epoll edge) while BudgetExhausted means “I have more
bytes available but I’m yielding voluntarily” (must come back
this loop or the next).
Auto scaling (CBRD-26406)
Section titled “Auto scaling (CBRD-26406)”CBRD-26406 wires the mechanism for connection-rebalancing and
worker-count scaling; the policy lives in CBRD-26424
(score-based selection, below). The mechanism is simple in
shape: workers report statistics on a 1-second timer, the
coordinator’s 5-second REBALANCING timer compares per-worker
scores and asks the heaviest worker to hand off one of its
connections to the lightest, the coordinator’s 60-second
SCALING timer drives the auto-scaling state machine.
The scaling_status enum has only two states:
STABLE— current count is “good enough”, no measurement in progress.TRIAL— sweep throughcountcandidate sizes recording their throughput score, then pick the best.
At each SCALING tick:
// coordinator::statistics_scaling — src/connection/coordinator.cppif (m_scaling_statistics.status == scaling_status::STABLE) { this->scale_trial (); return true; }
assert (m_scaling_statistics.status == scaling_status::TRIAL);
bytes_inout = 0;for (i = 0; i < m_max_worker; i++) { bytes_inout += m_statistics[i].m_sum.get (statistics::context::BYTES_IN_TOTAL); bytes_inout += m_statistics[i].m_sum.get (statistics::context::BYTES_OUT_TOTAL); }m_scaling_statistics.history.push_back ({ m_current_worker, VAL_TO_SCORE (50, 1000, bytes_inout) + m_task_statistics.completed.first * 2 });m_scaling_statistics.count--;
if (m_scaling_statistics.count == 0) { selected = this->scale_selection (); /* pick max-score scale */ if (selected < m_current_worker) this->scale_down (); else if (selected > m_current_worker) this->scale_up (); /* else stable */ }else { if (m_scaling_statistics.direction == scaling_direction::DOWN) this->scale_down (); else this->scale_up (); }scale_trial clears the history, alternates the trial direction
relative to the previous one (so consecutive trials don’t drift
uni-directionally), and sets count to the
auto_scaling_window_size parameter — the hyper-parameter that
trades trial length for sensitivity. The default of 4 means each
trial collects 4 samples (one per SCALING tick = 60 s) before
deciding.
Sliding-window mechanism:
sequenceDiagram
participant T as SCALING timer (60s)
participant C as coordinator
participant H as history (window_size = 4)
Note over C: status = STABLE
T->>C: tick
C->>C: scale_trial()
Note over C: direction = DOWN (or UP)<br/>count = 4<br/>status = TRIAL
loop count = 4
T->>C: tick
C->>H: push_back({ current_worker, score })
C->>C: scale_down() or scale_up()
end
T->>C: tick
C->>H: push_back({ current_worker, score })
C->>C: selected = scale_selection()
alt selected != current
C->>C: scale_down() or scale_up() to reach selected
end
Note over C: status = STABLE again
Figure 7 — Auto-scaling sliding-window sequence. Each SCALING tick (60 s) pushes a (current_worker, score) sample into a window_size=4 history; after the trial window scale_selection picks the highest-scoring worker count and adjusts m_current_worker accordingly.
scale_selection picks any sample within 95% of the maximum
score, then chooses uniformly among them — a small Boltzmann-style
randomisation to avoid getting stuck at a flat local maximum
(see CBRD-26424 commentary on the dual local maxima observed in
small-machine measurements).
scale_up flips the next-in-line hibernating worker out of
HIBERNATING by sending an AWAKEN lazy message to it and
incrementing m_current_worker. scale_down does the reverse
in two phases: scale_down itself migrates every connection of
the draining worker via transfer_connection and parks the
coordinator status as DRAINING; scale_down_finish is the
actual hibernation, called from
handle_message_queue_statistics only once the draining worker
reports an empty context list. This two-phase shutdown is
necessary because worker shutdown is asynchronous and the
coordinator must not allow a worker to be re-targeted by
statistics_find_score_extremes while it is still serving
connections.
Coordinator + context freelist (CBRD-26407)
Section titled “Coordinator + context freelist (CBRD-26407)”The coordinator (cubconn::connection::coordinator in
coordinator.{cpp,hpp}) is structurally the same shape as a
worker — pinned thread, epoll instance, eventfd + timerfd,
single-producer-single-consumer (TBB) queue — but it owns three
distinct timers and an external Unix-domain control socket.
// coordinator::coordinator — src/connection/coordinator.cppm_controller.open ("/tmp/cub_server_" + std::to_string (getpid ()) + "_coordinator.sock", SOCK_NONBLOCK | SOCK_CLOEXEC);m_ctrlfd = m_controller.get_fd ();m_eventfd = eventfd (0, EFD_NONBLOCK | EFD_CLOEXEC);m_timerfd = timerfd_create (CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
eventfd_register (m_eventfd);eventfd_register (m_timerfd);eventfd_register (m_ctrlfd);
eventfd_addtimer (timer_type::STATISTICS, timer_latency::LOW_LATENCY, &coordinator::statistics_update);eventfd_addtimer (timer_type::REBALANCING, timer_latency::MEDIUM_LATENCY, &coordinator::statistics_rebalancing);eventfd_addtimer (timer_type::SCALING, timer_latency::HIGH_LATENCY, &coordinator::statistics_scaling);The three timer latencies are 1 second / 5 seconds / 60 seconds
respectively (see the timer_latency enum in coordinator.hpp).
The control socket exposes administrative commands:
SHOW_STATS— print per-worker EWMA throughput and queue depth (statistics_print) to stdout.SCALE_UP/SCALE_DOWN— force one step of the auto-scaling state machine.CLIENT_MOVE— manually transfer one connection by id from workerfromto workerto.
This is an out-of-band debugging interface; nothing in the data
path uses it. Sending a control_recv struct via
SOCK_DGRAM/SOCK_NONBLOCK triggers a reply with a single
OK/NOK byte. The directive from CBRD-26177 (“no perfmon on
the hot path”) means there is no SHOW server-side equivalent
through the standard server channel — the controller is
intentionally a side door, not a performance counter.
coordinator::handle_message_queue_new_client is where the
placement policy lands. Note that it calls the same EWMA-driven
score-extremes function used by rebalancing:
// coordinator::handle_message_queue_new_client — src/connection/coordinator.cppstd::tie (worker, std::ignore) = statistics_find_score_extremes ();
m_statistics[worker].m_contexts.emplace (id, std::pair</*EWMA*/, /*prev*/>{ });m_statistics[worker].m_client_num++;
request.type = connection::worker::message_type::NEW_CLIENT;request.ctx = m_parent->claim_context ();request.ctx->m_worker = worker;request.ctx->m_id = id++;request.conn = item.conn;workers[worker]->enqueue (queue_type::IMMEDIATE, std::move (request));workers[worker]->notify ();this->statistics_update_score (worker);— so every new client is immediately routed to the worker with the lowest current score, and that score is updated on the spot to bias the next placement.
The context migration protocol (used by both rebalancing and scale-down) is a four-step handshake between coordinator and two workers:
sequenceDiagram participant C as coordinator participant Wf as worker[from] participant Wt as worker[to] C->>C: m_migrating.insert(id) C->>Wf: HANDOFF_CLIENT(id, worker_ptr=Wt, worker_index=to) Wf->>Wf: locate ctx, remove from epoll/m_context Wf->>Wt: TAKEOVER_CLIENT(ctx) Wt->>Wt: register ctx in epoll (EPOLLIN | maybe EPOLLOUT) Wf->>C: HANDOFF_REPLY(transferred=true, id, from, to) C->>C: m_migrating.erase(id)<br/>fix m_statistics[from/to]
Figure 8 — Context migration handshake. Coordinator sends HANDOFF_CLIENT to worker[from]; worker[from] removes the context from its epoll and sends TAKEOVER_CLIENT to worker[to]; HANDOFF_REPLY closes the four-step transfer; m_migrating prevents double-targeting.
m_migrating prevents a connection from being targeted twice in
flight. If the worker discovers the context is already gone (the
client closed concurrently with the migration), the reply carries
transferred=false and the coordinator reverts the projected
stats. This is the single concurrency invariant the design
relies on: a context is only ever owned by exactly one
connection worker at a time, with ownership transferred via
explicit message. No locks are required around the context
itself — only the conn entry’s cmutex, briefly, for adapter
field updates.
The context freelist (described above under CBRD-26255) was finalised in this same ticket. The CBRD-26407 description states the goal directly:
“context는 생성마다 Physical Memory와 Virtual Memory를 할당받고 이를 mapping하므로 이 과정을 생략하도록 한다.” (Each context creation allocates physical and virtual memory and maps the two, so skip this process.)
By pre-warming the freelist with max_connections * 1.1
preallocated freelist nodes (each 32 KB capacity), the runtime
hot path is a pointer swap, not a mmap/page-fault sequence.
Score-based connection assignment (CBRD-26424)
Section titled “Score-based connection assignment (CBRD-26424)”The coordinator’s score function combines three signals into a single comparable scalar per worker:
// coordinator::statistics_update_score — src/connection/coordinator.cppm_statistics[worker].m_score = 1 * static_cast<double> (m_statistics[worker].m_client_num) / 1 + EVAL_WORKER (EWMA(MQ_COMPLETED), EWMA(BLOCKED_RMUTEX)) + EVAL_CONTEXT (EWMA(BYTES_IN_TOTAL) + EWMA(BYTES_OUT_TOTAL), EWMA(RECV_BUDGET_HIT) + EWMA(SEND_BUDGET_HIT));with the weight macros
#define VAL_TO_SCORE(w, m, s) ((w) * static_cast<double> (s) / (m))#define EVAL_WORKER(mq, rmutex) (VAL_TO_SCORE (25, 3.5, (mq)) + VAL_TO_SCORE (500, 1, (rmutex)))#define EVAL_CONTEXT(bytes, bgt) (VAL_TO_SCORE (50, 1000, (bytes)) + VAL_TO_SCORE (10, 1, (bgt)))Concretely the weights mean: bytes-of-traffic count for 50 ×
1/1000 (≈ 1 unit per kilobyte); rmutex blocked microseconds count
for 500 × 1 (≈ 500 units per microsecond blocked); MQ completions
count for 25 × 1/3.5 (≈ 7 units per completion). Budget-hit
events (i.e., contexts that hit the recv/send budget cap) are
weighted at 10 — because a high budget-hit count means the worker
is repeatedly running into its admission cap and would benefit
from an extra peer to share load. CBRD-26424’s commentary
explains the dual local maxima visible in measured throughput
curves: small machines exhibit a non-monotonic relationship
between worker count and throughput because of NUMA / RX-TX /
HT-sibling interactions, and a naïve hill-climber gets stuck.
The randomised top-5% selection in scale_selection is the
escape hatch.
EWMA aggregation uses α = 0.06 (EWMA_ALPHA):
// coordinator::statistics_EWMA — src/connection/coordinator.hppacc = acc * (1 - alpha) + (current - prev) * (alpha / (time_delta * 1e-6));prev = current;The division by time_delta * 1e-6 normalises to microseconds, so
the EWMA is a smoothed rate (events per microsecond) rather
than a raw delta. With α = 0.06 and a 1 s sampling interval the
effective half-life is roughly 11 samples (≈ 11 s); aged samples
contribute less than 1 % after about a minute.
Atomic-free stats (CBRD-26191)
Section titled “Atomic-free stats (CBRD-26191)”The statistics::metrics<T, VT = uint64_t> template
(connection_statistics.hpp) is a fixed-size VT[STATS_COUNT]
with add / sub / get / set / reset operations. There is
no std::atomic anywhere — every increment is a plain memory
write, because every increment is performed by exactly one
thread (the worker that owns the metric). Aggregation across
workers happens once per second, when the worker copies its
metric block into a coordinator::message::statistics payload
and the coordinator does a per-worker EWMA update inside its own
single-threaded handler:
// worker::statistics_metrics_to_coordinator — src/connection/connection_worker.cppmessage.type = coordinator::message_type::STATISTICS;message.statistics.cpu_time_ns = get_time_ns (CLOCK_THREAD_CPUTIME_ID);message.statistics.time_ns = get_time_ns (CLOCK_MONOTONIC);message.statistics.worker.first = m_index;message.statistics.worker.second = m_stats; /* copy */message.statistics.contexts.reserve (m_context.size ());for (context *ctx : m_context) message.statistics.contexts.emplace_back (ctx->m_id, ctx->m_stats); /* copy */m_coordinator->enqueue (std::move (message));The bulk copy is cheap because m_stats is a fixed array (≈ 88
bytes) and the per-context array is at most a few hundred entries
of 56 bytes each. The copy moves ownership across the
single-producer-single-consumer queue without crossing any
cache line that the worker is concurrently writing to. Crucially,
this design exists to uphold the CBRD-26177 directive
(“no perfmon on the hot path”): the worker never increments a
shared counter, never spins on a lock, never executes a memory
barrier in the dispatch loop.
CBRD-26191 measured the wider goal — strip atomics from server-wide monitoring — on YCSB:
| workload | before | after | gain |
|---|---|---|---|
| workloada | 58 464.28 | 60 646.59 | +3.7% |
| workloadb | 70 009.99 | 72 976.31 | +4.2% |
| update | 44 158.66 | 45 128.96 | +2.2% |
| mix | 9 440.82 | 10 115.33 | +7.1% |
The connection-side metrics design follows the same template at the new layer.
TCP keepalive tunables
Section titled “TCP keepalive tunables”CBRD-26177 promised three new per-socket keepalive parameters:
tcp_keepalive_idle (start probing after N seconds idle),
tcp_keepalive_interval (interval between probes),
tcp_keepalive_count (consecutive failures = dead). The defaults
are 300 s / 300 s / 3 with a high cap at 1 year of seconds. They
are registered in system_parameter.c alongside the existing
tcp_keepalive boolean and are intended to be applied by the
socket-setup helper (tcp.c::css_sockopt) which already calls
setsockopt (SOL_SOCKET, SO_KEEPALIVE, ...) when tcp_keepalive
is set; the three new knobs feed TCP_KEEPIDLE, TCP_KEEPINTVL,
TCP_KEEPCNT respectively for fine-grained tuning of dead-peer
detection. The CUBRIDMAN-333 manual update covers the
documentation rollout.
Task worker pool sizing — task_group / task_worker (interim, superseded by Phase 2)
Section titled “Task worker pool sizing — task_group / task_worker (interim, superseded by Phase 2)”Interim mechanism. The
task_group/task_workersizing below is Phase 1’s back-end tuning knob and is what shipped with the CBRD-26177 merge. Phase 2 (CBRD-26662) retires it:task_workeris renamed tomax_request_worker,task_groupis demoted to a deprecated no-op, and the real cap on simultaneously-running transactions moves to the newmax_request_concurrency. Read this subsection for the as-merged Phase 1 behaviour; read Phase 2 below for what replaces it.
The back-end pool in Phase 1 is cubthread::worker_pool in
thread_worker_pool_impl.{hpp,cpp}. Its sizing is controlled
by two parameters that replace the legacy
thread_core_count/thread_worker_count pair:
task_group(renamed fromthread_core_count) — number of cores in the worker pool. Each “core” in CUBRID terminology is a sub-pool with its own queue, owned by oneworker_pool::core.task_worker— total number of worker threads across all groups. Default at server startup:css_get_max_connections ()(i.e., effectively the legacymax_clients), normalised down if it exceeds the system core count.
The auto-tuning code clamps task_group ≤ system core count and
task_group ≤ task_worker (system_parameter.c boot
sysprm tuning block):
/* sysprm_tune_client_parameters — src/base/system_parameter.c */task_worker_prm = GET_PRM (PRM_ID_TASK_WORKER);if (PRM_GET_INT (task_worker_prm->value) < 0) { /* the value of task worker is default. */ sprintf (newval, "%d", task_worker); /* css_get_max_connections() */ (void) prm_set (task_worker_prm, newval, false); }
task_group_prm = GET_PRM (PRM_ID_TASK_GROUP);if (PRM_GET_INT (task_group_prm->value) > system_cpu_count) { sprintf (newval, "%d", system_cpu_count); (void) prm_set (task_group_prm, newval, false); }if (PRM_GET_INT (task_group_prm->value) > PRM_GET_INT (task_worker_prm->value)) { sprintf (newval, "%d", PRM_GET_INT (task_worker_prm->value)); (void) prm_set (task_group_prm, newval, false); }The semantic shift is that task_worker is now interpreted as the
total worker budget and task_group controls partitioning. The
legacy thread_core_count was loosely “number of cores” with no
policy; the new naming makes the intent explicit, and the
coordinator’s task-completion EWMA (m_task_statistics.completed)
uses css_get_task_stats from server_support.c to read the pool’s
running totals into the score.
CBRD-26636 (“[성능 실험] Worker 개수에 따른 성능 추이”) found
that task_worker ≈ 4–6 × cores consistently outperformed
task_worker = max_clients on read-heavy YCSB workloads, but at
the cost of a deadlock risk when task_worker < max_clients and
many workers wait on a long lock. That risk motivates CBRD-26662
(see Phase 2 below).
CUBRID’s Approach: Phase 2 — Logical-Wait-Aware Concurrency Control (CBRD-26662)
Section titled “CUBRID’s Approach: Phase 2 — Logical-Wait-Aware Concurrency Control (CBRD-26662)”Status: implemented in PR #7323 (feature/worker_pool_elastic, 21
commits, 45 files, +4054/−801), open against develop; test cases
pending before merge. Back-end redesign.
Phase 2 replaces the fixed task_group × task_worker request pool
with an elastic worker pool whose true concurrency is governed by
a pool of slots rather than by a fixed thread count. The design
realises the theory above: a worker must hold a slot to run, the
slot count is capped at max_request_concurrency, and a worker that
enters a logical wait surrenders its slot so another unit of work
can run. Threads may overcommit up to max_request_worker.
The most important structural fact — and the reason the change is far
smaller than “rewrite the lock manager” — is that the lock manager,
page-buffer, and CSS queue are not modified. All three already
suspend a thread through the same primitive in thread_entry.cpp, and
Phase 2 injects the slot release/reacquire there, keyed off the
existing suspended_reason — so one narrow site handles the slot
hand-off for a transaction lock wait (soft) and a CSS job-queue idle
(immediate), rather than edits scattered across each subsystem. A
page-buffer latch wait funnels through the same primitive but is not
slot-managed today — it only records a trace timestamp.
Three JIRA-to-code naming clarifications, because the ticket text and the shipped code diverge:
- The EPIC’s
high_concurrencyand sub-task CBRD-26688’smax_transaction_concurrencyboth becamemax_request_concurrencyin the code; the worker capmax_transaction_workerbecamemax_request_worker. - CBRD-26688 says “remove
task_groupandtask_worker”. In the code onlytask_workeris removed (renamed tomax_request_worker);task_groupis retained as a deprecated no-op (PRM_DEPRECATED, hard-coded to1) so existingcubrid.conffiles do not fail. - CBRD-26688’s constraint line reads
max_transaction_worker <= max_transaction_concurrency, which is inverted; the code enforces the sensible direction,max_request_worker >= max_request_concurrency(workers are the overcommit ceiling above the active cap).
Elastic worker pool (CBRD-26684)
Section titled “Elastic worker pool (CBRD-26684)”The elastic pool is cubthread::worker_pool_elastic<Stats> in
thread_worker_pool_elastic.hpp, a subclass of the reworked
worker_pool_impl<Stats>. It keeps the Phase 1 structure — one
core_elastic per CPU core, each with its own task queue — but each
core now also owns a concurrency_slot_pool m_slots, and the two
sizing numbers become pool-wide atomics:
// worker_pool_elastic / core_elastic — src/thread/thread_worker_pool_elastic.hppstd::atomic<std::size_t> &m_current_worker; // shared across all coresstd::atomic<std::size_t> &m_max_worker; // global overcommit cap// global. this is not hard cap. allow to make worker overcommitThat the worker counters are global (shared by reference across
every core_elastic) rather than per-core is the CBRD-26977 hardening
change bundled into the same PR — it makes max_request_worker a
whole-server ceiling, not a per-core one.
Dispatch is slot-gated. When work arrives or a slot frees up,
core_elastic::adjust_workers drains the queue only as far as
available slots permit:
// core_elastic::adjust_workers — src/thread/thread_worker_pool_elastic.hppwhile (!m_task_queue.empty () && m_slots.available_slots () > 0) { worker_elastic *w = get_or_make_available_worker (); // reuse idle, else spawn if (w == nullptr) break; // hit m_max_worker std::unique_ptr<concurrency_slot> slot = m_slots.try_acquire_slot (ulock); // pop one task, hand it the slot w->try_execute_task_with_slot (std::move (task), std::move (slot)); }get_or_make_available_worker reuses an idle worker if one exists;
otherwise it reserve_available_worker() (a CAS bounded by the global
m_max_worker) and allocate_worker() → new worker_elastic() →
start_thread(). This is the only place a fresh worker thread is
born, and it happens precisely when there is queued work, a free
slot, and no idle worker to run it — i.e. when a running worker has
surrendered its slot to a logical wait.
A worker_elastic moves its slot into the thread entry for the
duration of the task and returns it afterward:
// worker_elastic::execute_current_task — src/thread/thread_worker_pool_elastic.hppm_context_p->m_slot = std::move (m_slot); // entry now holds the slotm_wrapped_task->execute (*m_context_p); // run the task// ... on completion, the slot is returned to its pool ...m_slot = std::move (m_context_p->m_slot);concurrency_slot::return_to_pool (std::move (m_slot)); // return_slot drives this on the completion pathWorkers self-retire when the pool has shrunk: worker_elastic::run
calls get_retire_if_excess(this) after each task, which tears the
worker down once m_workers.size () > m_max_concurrency and no
snapshot reader is iterating the worker list.
Figure 9 — Elastic request pool. Each core_elastic owns a task queue, a concurrency_slot_pool, and a variable set of worker_elastic threads. A worker must acquire a slot to run; the number of slots caps active concurrency while threads may overcommit up to the pool-wide m_max_worker. The 50 ms slot daemon rebalances slots across cores.
Concurrency slots and the per-core slot pool (CBRD-26685 / 26689)
Section titled “Concurrency slots and the per-core slot pool (CBRD-26685 / 26689)”A concurrency_slot (in concurrency_slot.hpp) is a movable token.
It remembers the core that created it and the core currently
using it — slots migrate between cores — plus a soft-wait flag and
timestamp:
// concurrency_slot — src/thread/concurrency_slot.hppclass concurrency_slot{ concurrency_slot_pool *const m_owner_pool; // core that created the slot concurrency_slot_pool *m_holder_pool; // core currently using it bool m_wait; // soft-wait flag std::chrono::steady_clock::time_point m_wait_since; public: void reset (); void start_waiting (); void stop_waiting (); bool has_wait_expired (now) const; // threshold: 50 ms static void return_to_pool (std::unique_ptr<concurrency_slot> slot);};The per-core pool concurrency_slot_pool is guarded by the owning
core’s mutex (there is no separate slot lock — it piggy-backs on the
core lock, so acquiring a slot and popping a task are one critical
section):
// concurrency_slot_pool — src/thread/concurrency_slot.hppclass concurrency_slot_pool : public concurrency_slot_subscriber{ std::mutex *m_mutex; // the core mutex std::queue<std::unique_ptr<concurrency_slot>> m_available_slots; std::list<cubthread::entry *> m_wait_queue; // threads waiting for a slot std::size_t m_slot_count; // slots this core owns std::size_t m_target_count; // desired count bool m_surplus; std::chrono::steady_clock::time_point m_surplus_since; static constexpr std::size_t SLOT_SURPLUS_THRESHOLD = 2;};Slot management is entirely per-core, on the assumption that tasks distribute evenly across cores, so capping concurrency per-core caps it globally (CBRD-26685’s stated rationale). The key operations:
-
try_acquire_slot— non-blocking; pop fromm_available_slotsor returnnullptr. -
acquire_slot(entry *thread_p)— blocking. If a slot is free, assign it tothread_p->m_slot; otherwise enqueuethread_ponm_wait_queue, set its resume status toTHREAD_CONCURRENCY_SLOT_SUSPENDED, andpthread_cond_waituntil a slot is handed over. Crucially it saves and restores the thread’s original resume status, because a slot wait can nest inside a lock wait and must not clobber the lock’s wake result:// concurrency_slot_pool::acquire_slot — src/thread/concurrency_slot.cpp (condensed)if (!m_available_slots.empty ()){thread_p->m_slot = std::move (m_available_slots.front ());m_available_slots.pop ();thread_p->m_slot->set_holder_pool (this);return;}m_wait_queue.push_back (thread_p);auto saved = thread_p->resume_status; // nested wait guardthread_p->resume_status = THREAD_CONCURRENCY_SLOT_SUSPENDED;while (thread_p->resume_status == THREAD_CONCURRENCY_SLOT_SUSPENDED)pthread_cond_wait (&thread_p->wakeup_cond, &thread_p->th_entry_lock);thread_p->resume_status = saved; -
release_slot(slot)—reset()the slot; if this core owns it and is over target, destroy it; otherwise store it inm_available_slotsor, if a thread is waiting, hand it straight to the front waiter and wake it withthread_wakeup_already_had_mutex (waiter, THREAD_CONCURRENCY_SLOT_RESUMED). -
return_slot(slot, force)— the completion path; returns the slot to a pool when there is queued work, a waiter, an over-target condition, orforce, and otherwise settles the slot back toward its owner core viaconcurrency_slot::return_to_pool. -
get_score()=-available_slots.size () + wait_queue.size () * 2.0f— the demand rank the daemon sorts on (higher = hungrier). -
get_runtime_stats(total, target, busy)— feeds statdump;busy = m_slot_count − m_available_slots.size ().
Figure 10 — Per-core concurrency slot pool. m_available_slots holds free slot tokens; m_wait_queue holds threads suspended with THREAD_CONCURRENCY_SLOT_SUSPENDED while they wait for one. Each concurrency_slot records the core that created it (m_owner_pool) and the core using it now (m_holder_pool), so slots migrate between cores; get_score() ranks a core’s demand for the daemon. Everything runs under the core m_mutex.
stateDiagram-v2 [*] --> Available : pool initialize \n one per max_request_concurrency Available --> Running : try_acquire_slot \n worker takes a task Running --> Available : task done \n return_slot Running --> Available : THREAD_CSS_QUEUE_SUSPENDED \n idle worker returns slot at once Running --> SoftWait : THREAD_LOCK_SUSPENDED \n start_waiting, keeps slot SoftWait --> Running : lock granted within 50 ms \n stop_waiting SoftWait --> Reclaimed : daemon sees has_wait_expired \n steal_from_entries_if_excess Reclaimed --> Available : distribute_slots to a hungry core
Figure 11 — Slot lifecycle. A slot is Available in a per-core pool, Running while a worker executes, and enters SoftWait on a lock suspension. If the lock is granted within 50 ms the worker keeps its slot; otherwise the daemon reclaims it and redistributes it. A CSS-queue idle returns the slot immediately. The worker whose slot was reclaimed must re-acquire one before resuming (Figure 12).
The slot daemon — soft/hard wait and inter-core transfer (CBRD-26686 / 26691)
Section titled “The slot daemon — soft/hard wait and inter-core transfer (CBRD-26686 / 26691)”The redistributor is a single daemon, concurrency_slot_daemon,
registered with a 50 ms looper and named "concurrency":
REGISTER_DAEMON (concurrency_slot_daemon);looper loop = looper (std::chrono::milliseconds (50));cubthread::get_manager ()->create_daemon (loop, new concurrency_slot_daemon_task (), "concurrency");Its execute runs a five-step pass over every registered core pool
(cores register themselves with the daemon via the
publisher/subscriber pair concurrency_slot_publisher /
concurrency_slot_subscriber):
// concurrency_slot_daemon_task::execute — src/thread/concurrency_slot.cpp (steps)1. check_and_propagate_parameters (); // apply runtime param changes2. steal_from_entries_if_excess (slots, identifier); // reclaim slots from expired lock-waiters3. steal_from_cores_if_excess (slots, subs); // reclaim per-core surplus4. distribute_slots (slots, subs); // give slots to hungriest cores5. wakeup_workers (identifier, subs); // core_elastic::adjust_workersThe soft-wait / hard-wait distinction lives in steps 2 and the suspension code. When a worker blocks on a lock it does not release its slot immediately (a soft wait): it just records the wait start time. Only if the wait outlasts the 50 ms threshold does the daemon promote it to a hard wait by stealing the slot:
// steal_from_entries_if_excess — via pool->map_running_contexts (func)if (thread_ref.m_status == cubthread::entry::status::TS_WAIT && thread_ref.m_slot != nullptr && thread_ref.m_slot->has_wait_expired (now)) // held > 50 ms { steal thread_ref.m_slot; // hard wait: reclaim }This two-tier scheme avoids churn: a lock wait that resolves quickly (the common case) never touches the daemon or gives up its slot, so short waits cost nothing; only genuinely long waits release capacity to other work.
Step 3 handles inter-core slot transfer. A core that has held a
surplus of ≥ SLOT_SURPLUS_THRESHOLD (2) available slots for longer
than 2000 ms hands the excess back:
// concurrency_slot_pool::borrow_surplus_slots — src/thread/concurrency_slot.cppif (m_surplus && now - m_surplus_since > std::chrono::milliseconds (2000) && m_available_slots.size () >= SLOT_SURPLUS_THRESHOLD) { hand back the surplus slots; // returned to owners / hungry cores }Step 4 sorts the pools by get_score() (descending demand) and
round-robins the reclaimed slots to the hungriest cores; a borrowed
slot is returned to its owner core once the borrowing work finishes,
via the m_owner_pool / m_holder_pool bookkeeping on the slot.
sequenceDiagram participant W as worker (entry) participant SP as core slot pool participant D as slot daemon (50 ms) participant PEER as hungry core W->>SP: blocks on lock → THREAD_LOCK_SUSPENDED W->>W: thread_prepare_suspension<br/>start_waiting() — soft wait, keeps slot W->>W: pthread_cond_wait (suspended) Note over D: every 50 ms D->>W: has_wait_expired(50 ms)? yes<br/>steal m_slot (hard wait) D->>PEER: distribute_slots → release_slot PEER->>PEER: wake a waiter / spawn worker Note over W: later, lock granted W->>W: thread_prepare_resumption<br/>slot was stolen W->>SP: acquire_slot (blocking) — charged to slot_waits SP-->>W: slot granted → resume
Figure 12 — Lock-wait slot hand-off. A lock-blocked worker keeps its slot for up to 50 ms (soft wait). If the daemon finds the wait expired it reclaims the slot (hard wait) and redistributes it to a hungry core, which spins or wakes a worker. When the original worker is finally granted its lock it must re-acquire a slot before it can resume; that re-acquire time is accounted to event_stats.slot_waits.
Wiring logical waits — the suspension layer, locks, and SP (CBRD-26687 / 26683)
Section titled “Wiring logical waits — the suspension layer, locks, and SP (CBRD-26687 / 26683)”The integration point is src/thread/thread_entry.cpp. The two
existing entry points thread_suspend_wakeup_and_unlock_entry and
thread_suspend_timeout_wakeup_and_unlock_entry were refactored to
funnel through two new static helpers, thread_prepare_suspension and
thread_prepare_resumption, which is where the slot logic lives. On
suspend, the behaviour branches on the existing suspended_reason:
// thread_prepare_suspension — src/thread/thread_entry.cpp (condensed)if (thread_p->m_slot != nullptr) switch (suspended_reason) { case THREAD_CSS_QUEUE_SUSPENDED: // worker idling on the job queue holder = thread_p->m_slot->get_holder_pool (); slot = std::move (thread_p->m_slot); thread_p->m_slot = nullptr; concurrency_slot::return_to_pool (std::move (slot)); // release immediately break; case THREAD_LOCK_SUSPENDED: // waiting to acquire a transaction lock holder = thread_p->m_slot->get_holder_pool (); thread_p->start_waiting (); // soft wait; daemon may reclaim after 50 ms break; }On resume, if a holder was recorded (asserting the reason was a
CSS-queue or lock suspension), the worker’s fate depends on whether
its slot survived:
// thread_prepare_resumption — src/thread/thread_entry.cpp (condensed)if (timedout || resume_status == THREAD_RESUME_DUE_TO_INTERRUPT || resume_status == THREAD_RESUME_DUE_TO_SHUTDOWN) { if (thread_p->m_slot) release_slot (std::move (thread_p->m_slot)); // giving up }else if (thread_p->m_slot != nullptr) { thread_p->stop_waiting (); // wait resolved < 50 ms; slot never stolen }else { // slot was stolen by the daemon — must re-acquire, blocking static_cast<concurrency_slot_pool *> (holder)->acquire_slot (thread_p); // wait time charged to thread_p->event_stats.slot_waits }Two supporting facts make this work. First, new resume statuses were
added to thread_entry.hpp — THREAD_CONCURRENCY_SLOT_SUSPENDED = 25
and THREAD_CONCURRENCY_SLOT_RESUMED = 26 — along with the
std::unique_ptr<cubthread::concurrency_slot> m_slot member (“only set
for workers from an elastic worker pool”) and a struct timeval slot_waits inside event_stat. Second, network_sr.c adds
THREAD_CONCURRENCY_SLOT_SUSPENDED to the net_server_wakeup_workers
wake-eligible switch so a slot-suspended worker can be interrupted like
any other.
Stored procedures (CBRD-26687 / temp-worker removal CBRD-26683).
The SP path is the explicit (non-suspension) logical wait: when a PL
method calls a nested method it blocks on a receive from the PL/Java
server. Legacy CUBRID spawned a temporary worker per nested call to
avoid parking the pool; Phase 2 deletes that mechanism entirely
(execute_on_core(task, core_hash, bool is_temp) →
execute_on_core(task, core_hash), and execute_task(task, is_temp)
→ execute_task(task)). Instead the SP call releases its slot around
the blocking receive and reacquires afterward, using the explicit
helpers thread_concurrency_slot_release / thread_concurrency_slot_acquire
from thread_manager.hpp:
// pl_execution_stack_context.hpp — around a blocking PL receiveauto *holder = thread_concurrency_slot_release (m_thread_p); // give up slot nowint error = conn->receive_buffer (b, &interrupt_func, 500); // wait on PL serverthread_concurrency_slot_acquire (m_thread_p, holder); // reacquire to resumeThe difference from the lock path is the timing of the release: a
lock wait is a soft wait (keep the slot, let the daemon reclaim only
if it drags on), because most lock waits are short; a nested SP call
is known up front to block on a different task, so it releases the slot
immediately. Both funnel to the same concurrency_slot_pool.
Parameters: max_request_concurrency / max_request_worker (CBRD-26688 / 26690 / 26977)
Section titled “Parameters: max_request_concurrency / max_request_worker (CBRD-26688 / 26690 / 26977)”task_worker is renamed to max_request_worker, task_group is
deprecated to a no-op, and max_request_concurrency is added. Both
new parameters are PRM_FOR_SERVER | PRM_USER_CHANGE, i.e. changeable
at runtime with SET SYSTEM PARAMETERS:
| Parameter | Enum id | Default (server) | Range | Runtime | Meaning |
|---|---|---|---|---|---|
max_request_concurrency | PRM_ID_MAX_REQUEST_CONCURRENCY | system_core_count() * 3 | [core*3 … CSS_MAX_CLIENT_COUNT] | yes | max simultaneously-running transactions (total slots) |
max_request_worker | PRM_ID_MAX_REQUEST_WORKER | CSS_MAX_CLIENT_COUNT | [… CSS_MAX_CLIENT_COUNT] | yes | max worker threads (overcommit ceiling) |
task_group | PRM_ID_TASK_GROUP | 1 (deprecated no-op) | — | no | retained only so old configs still load |
The × 3 default is CBRD-26636’s empirical pick (a source comment
reads /* adjusted based on CBRD-26636 */), where core × 2 and
core × 3 gave the best throughput plateau. When the values are not
user-set, prm_tune_parameters derives them from the client limit:
/* prm_tune_parameters — src/base/system_parameter.c (SERVER_MODE, condensed) */client_limit = std::min (max_clients, CSS_MAX_CLIENT_COUNT);factor = number_of_right_shifts (client_limit - 1); /* clamped >= 3 */max_value = std::min (std::min (cpu * factor, cpu * 16), std::min (client_limit, CSS_MAX_CLIENT_COUNT / 2));max_request_concurrency = std::max (cpu, max_value);max_request_worker = std::min (max_request_concurrency * 2 + PRM_REQUEST_WORKER_ELASTIC_HEADROOM /* 32 */, CSS_MAX_CLIENT_COUNT);/* invariant enforced: max_request_worker >= max_request_concurrency */Runtime changes (CBRD-26690). Because system parameters are polled
rather than event-driven, the slot daemon re-checks them every tick in
check_and_propagate_parameters, and on a change calls
css_set_max_concurrency_and_workers →
worker_pool_elastic::adjust_runtime_parameter, then writes the
clamped values back with prm_set_integer_value:
- Raising
max_request_concurrencysplits the new total evenly across cores (quotient+remainder) and supplies each core’s slot pool with more slots; lowering it lets the surplus slots be destroyed on return (applied lazily). - Raising
max_request_workerlifts the global overcommit cap; lowering it blocks new worker creation and lets completed workers retire down toward the pool target.
cgroup awareness (CBRD-26977). The same PR makes the worker cap a
global atomic (above) and teaches system_core_count() to read the
CPU quota from cgroup v1 with a v2 fallback (cgroup.cpp gains
quota_v1 / effective_v1 and a quota() dispatcher), so “core
count” — which seeds all of the defaults above — reflects a
container’s CPU limit rather than the host’s physical cores.
Observability: statdump and SHOW JOB QUEUES (CBRD-26692)
Section titled “Observability: statdump and SHOW JOB QUEUES (CBRD-26692)”Because the CBRD-26177 “no perfmon on the hot path” rule still holds,
concurrency is observed through peek statistics computed on demand,
not hot-path counters. Six new single-peek PSTAT metrics are added
(perf_monitor.{c,h}), filled by css_get_thread_runtime_stats which
sums each core’s concurrency_slot_pool::get_runtime_stats:
$ cubrid statdump demodb | grep Num_requestNum_request_concurrency_total = 100 # slots that existNum_request_concurrency_target = 100 # desired slot countNum_request_concurrency_busy = 1 # slots currently heldNum_request_worker_total = 100 # worker threads aliveNum_request_worker_target = 100 # desired worker countNum_request_worker_busy = 1 # workers currently running a taskThe same six values re-column SHOW JOB QUEUES (its scan column count
went 4 → 7 in show_meta.c / server_support.c, and the scan mapper
css_wp_core_job_scan_mapper became a template over the worker-pool
core type). Together with the per-thread event_stats.slot_waits
accounting, these are the operator-facing signals for tuning
max_request_concurrency against a real workload.
Source Walkthrough
Section titled “Source Walkthrough”Symbols are grouped by subsystem. CBRD-* annotations attribute
each symbol to its driving ticket where one is identifiable. The
first groups (epoll, connection::*, worker_pool sizing, keepalive)
are Phase 1 (CBRD-26177); the later groups (elastic pool, concurrency
slot, slot daemon, suspension integration) are Phase 2 (CBRD-26662,
PR #7323).
Figure 13 — File and subsystem map. The connection reactor lives in src/connection/ (Phase 1, CBRD-26177): connection_pool owns the connection_worker epoll threads and the coordinator, with per-connection state in connection_context / connection_statistics / controller. server_support.c is the bridge — css_push_server_task hands a completed request from the reactor into the back-end task pool under src/thread/ (the thread_worker_pool interface, thread_worker_pool_impl, thread_manager, thread_entry). Phase 2 (CBRD-26662, PR #7323, dashed green) layers thread_worker_pool_elastic and concurrency_slot on the same base pool. src/base/epoll underpins the reactor and src/base/system_parameter feeds both layers; the wiring files integrate suspension (network_sr.c, pl_execution_stack_context.hpp) and observability (show_meta.c).
epoll wrapper (CBRD-26212)
Section titled “epoll wrapper (CBRD-26212)”cubsocket::epoll(class,src/base/epoll.hpp) — RAII wrapper overepoll_create1/epoll_ctl/epoll_wait. Constructor opens anEPOLL_CLOEXECinstance; destructor closes it.cubsocket::epoll::wait— thin shim overepoll_wait.cubsocket::epoll::add_descriptor—EPOLL_CTL_ADDwith optionalvoid *ptrpayload (used to thread context pointers throughevents[i].data.ptr).cubsocket::epoll::modify_descriptor—EPOLL_CTL_MOD, used to add/removeEPOLLOUTwhen the transmitter queues pending data.cubsocket::epoll::remove_descriptor—EPOLL_CTL_DEL.cubsocket::nonblocking(parent class,nonblocking.hpp) — defines theresultenum (Ok,Pending,BudgetExhausted,PeerReset,Error,ClosedConnection,Skewed,Aborted) that every receiver/transmitter/worker call returns.
connection::worker (CBRD-26212 / 26392 / 26406 / 26407 / 26617)
Section titled “connection::worker (CBRD-26212 / 26392 / 26406 / 26407 / 26617)”cubconn::connection::worker— class definition inconnection_worker.hpp. Members includem_parent(pool),m_coordinator,m_watcher, the per-thread state (m_thread,m_core,m_status,m_stop,m_entry), the context set (m_context,m_removed_context), the epoll (m_events), the eventfd/timerfd (m_eventfd,m_timerfd), the timer table (m_timer_handler), the dual-priority message queues (m_queue[IMMEDIATE/LAZY],m_queue_size[]), the budget knobs and exhausted map (m_recv_budget,m_send_budget,m_exhausted), and the worker-side metrics (m_stats).worker::worker— constructor; reads system parameters, installs three timers, spawns the thread.worker::attach— thread entry; callsinitialize → run → finalize.worker::initialize— sets affinity, claims thread entry, sets pthread name"connections"(the name leak CBRD-26617 caught).worker::run— main reactor loop.worker::finalize— drain still-open contexts, retire thread entry, signal watcher.worker::enqueue/worker::notify/worker::enqueue_and_notify— outside-thread interface.worker::push_task_into_worker_pool— single-line bridge tocss_push_server_task(the back-end pool).worker::handle_reception/worker::handle_transmission— per-fd I/O drivers; honourm_recv_budget/m_send_budgetand emitBudgetExhausted. (CBRD-26392)worker::handle_exhausted_add_context/worker::handle_exhausted— exhausted-fd revisitation queue. (CBRD-26392)worker::handle_message_queue_new_client— bind a fresh context to a fd; register in epoll withEPOLLET|EPOLLIN|EPOLLRDHUP.worker::handle_message_queue_handoff_client/worker::handle_message_queue_takeover_client— the two halves of the migration handshake. (CBRD-26406 / CBRD-26407)worker::handle_message_queue_send_packet/worker::handle_message_queue_release_packet— task workers shipping bytes back to a connection use these messages instead of writing the socket directly. Sending may addEPOLLOUTto the fd if the transmitter buffers data.worker::handle_message_queue_shutdown_client— close- connection request from outside; callshandle_connection_close.worker::handle_message_queue_hibernate/worker::handle_message_queue_awaken— auto-scaling state transitions.worker::handle_connection_close— six-step close protocol with retry-via-LAZY-queue when back-end task workers still hold the conn.worker::statistics_metrics_to_coordinator— every MEDIUM tick (1 s default), copym_statsplus per-context metrics into acoordinator::message::STATISTICS. (CBRD-26191)worker::hibernate_check— every MEDIUM tick, if status isHIBERNATINGandm_context.empty(), stop the timer.worker::ha_close_all_connections— every HIGH tick, ifcss_ha_server_state () == HA_SERVER_STATE_TO_BE_STANDBY, forcibly close all idle connections — the HA mode-change path that interacts with CBRD-26523.
connection::pool (CBRD-26255 / 26407)
Section titled “connection::pool (CBRD-26255 / 26407)”cubconn::connection::pool::freelist— the singly-linked context cache node.pool::initialize/pool::finalize— top-level bring-up / tear-down; called by the executable wire-up.pool::initialize_topology— interrogatesos::resources::cpu::effective ()and (where capable)os::resources::net::map_nic_to_index ().pool::initialize_freelist— pre-allocatemax_connections * 1.1freelist nodes.pool::initialize_workers— createmax_connection_workerspinned workers and pre-warm by sending each aSTARTmessage on both queues.pool::initialize_coordinator/pool::start_coordinator/pool::finalize_coordinator— coordinator lifecycle.pool::dispatch— accept hand-off; called bymaster_connectoronce a TCP connection has completed CUBRID handshake. Sends aNEW_CLIENTto the coordinator.pool::claim_context/pool::retire_context— freelist API; requirem_mutexheld by the calling thread.pool::lock_resource/pool::release_resource/pool::try_to_lock_resource— the pool-wide mutex used by the coordinator for the duration of its lifetime.
connection::coordinator (CBRD-26406 / 26407 / 26424)
Section titled “connection::coordinator (CBRD-26406 / 26407 / 26424)”cubconn::connection::coordinator— class definition incoordinator.hpp. Members includem_parent,m_watcher, the controller (Unix-domain socketm_controller,m_ctrlfd), the message queue (m_queue,m_queue_size), the worker count tracking (m_max_worker,m_min_worker,m_current_worker), the migration-in-flight set (m_migrating), the scaling bookkeeping (m_scaling,m_scaling_statistics), and per-worker statistics (m_statistics).coordinator::coordinator— opens the controller socket, registers fds into epoll, installs three timers, spawns thread.coordinator::run— main reactor loop.coordinator::initialize— pin to core 0 (or the first effective core), claim thread entry, set name"coordinator", take the pool lock for life.coordinator::handle_message_queue_new_client— placement: pick min-score worker, allocate context, forwardNEW_CLIENT. (CBRD-26424)coordinator::handle_message_queue_return_to_pool— bulk return from a worker’sm_removed_context; clears per-context stats and callspool::retire_context.coordinator::handle_message_queue_handoff_reply— finalise migration; revert stats ontransferred=false.coordinator::handle_message_queue_statistics— per-worker stats arrival; runs EWMA update viastatistics_update_connection, thenstatistics_update_score; if the reporting worker is the currentdraining_workerand reports empty contexts, callsscale_down_finish. (CBRD-26424)coordinator::handle_message_queue_shutdown— flipm_stoptrue.coordinator::transfer_connection— guarded bym_migrating; sendsHANDOFF_CLIENTto the source worker.coordinator::scale_up—AWAKENnext worker, bumpm_current_worker. (CBRD-26406)coordinator::scale_down/coordinator::scale_down_finish— drain target worker’s connections, thenHIBERNATE. (CBRD-26406)coordinator::scale_trial/coordinator::scale_selection/coordinator::statistics_scaling— the auto-scaling state machine. (CBRD-26406 / CBRD-26424)coordinator::statistics_rebalancing— every MEDIUM tick (5 s), find score extremes, transfer one context if the gap exceeds 20 % of the high score. (CBRD-26424)coordinator::statistics_EWMA— α = 0.06, microsecond-normalised, used for both worker and context metrics.coordinator::statistics_find_score_extremes— linear scan overm_statistics[0..m_current_worker)returning(min_index, max_index).coordinator::statistics_update_score— applies theEVAL_WORKER + EVAL_CONTEXT + client_numformula.coordinator::statistics_print— controller-driven console dump of per-worker score, EWMA, byte counts.coordinator::handle_controller/coordinator::handle_controller_request— dispatch the four control-socket commands.
connection::context, controller, statistics
Section titled “connection::context, controller, statistics”cubconn::connection::context— per-client state (worker index, id, ignore guard, recv state machine, receiver, transmitter, blocker shared_ptr, per-context metrics). 32 KB inline send/recv buffer.cubconn::connection::context::reset— reset for reuse via the freelist.cubconn::thread_watcher—mutex + cv + int activeused for ordered shutdown.cubconn::message_blocker— single-shotmutex + cv + bool doneused for blockingenqueue_and_notifycallers.cubconn::connection::controller<RX,TX>— templated Unix-domain datagram socket wrapper (controller.hpp).cubconn::statistics::context/cubconn::statistics::worker— enums of metric keys (connection_statistics.hpp).cubconn::statistics::metrics<T,VT>— fixed-size array of counters; supports+=,-(returnsmetrics<T,double>),*(scaling),add,sub,get,set,reset,copy_from. No atomics. (CBRD-26191)
task worker pool changes (Phase 1)
Section titled “task worker pool changes (Phase 1)”Phase 1 back-end symbols. Phase 2 (CBRD-26662) replaces this pool for
the request path with worker_pool_elastic — see the elastic-worker-pool
group below.
cubthread::worker_pool(thread_worker_pool.hpp) — abstract interface (Phase 2 renamesget_worker_count→get_pool_sizeand drops theis_tempparameter).cubthread::worker_pool::core— in Phase 1 sized bytask_group; in Phase 2 the request pool has one core per CPU core.cubthread::worker_pool::execute/execute_on_core— entry points called fromcss_push_server_task.cubthread::worker_pool_task_capper(thread_worker_pool_taskcap.{hpp,cpp}) — the legacy admission-cap wrapper retained for HA daemons;m_tasks_available = m_max_tasks = worker_pool->get_worker_count ().css_push_server_task(server_support.c) — the hot-path handoff; partitions bystatic_cast<size_t> (conn_ref.idx)so a connection always lands on the same task-pool core.css_get_task_stats(server_support.c) — fillsstats[3] = { requested, started, completed }from the pool’s internal counters; consumed bycoordinator::statistics_update_task.
system parameters
Section titled “system parameters”PRM_ID_TCP_KEEPALIVE_IDLE/PRM_ID_TCP_KEEPALIVE_INTERVAL/PRM_ID_TCP_KEEPALIVE_COUNT— keepalive tunables.PRM_ID_TASK_GROUP(renamed fromthread_core_countin Phase 1; deprecated to a1-valued no-op in Phase 2 —PRM_DEPRECATED).PRM_ID_TASK_WORKER— removed in Phase 2, renamed toPRM_ID_MAX_REQUEST_WORKER(see Phase 2 symbols).PRM_ID_CSS_MAX_CONNECTION_WORKER/PRM_ID_CSS_MIN_CONNECTION_WORKER(Phase 2 retunes the server default tomax (system_core_count () / 2, 1)).PRM_ID_CSS_AUTO_SCALING_WINDOW_SIZE.PRM_ID_CSS_RECV_BUDGET_PER_CONNECTION/PRM_ID_CSS_SEND_BUDGET_PER_CONNECTION.
elastic worker pool (CBRD-26684 / 26683)
Section titled “elastic worker pool (CBRD-26684 / 26683)”cubthread::worker_pool_elastic<Stats>(thread_worker_pool_elastic.hpp) — subclass ofworker_pool_impl<Stats>; hosts onecore_elasticper CPU core.worker_pool_elastic::adjust_runtime_parameter (max_concurrency, max_worker)— splitmax_concurrencyevenly across cores, apply to each core’s slot pool and worker set.worker_pool_elastic::core_elastic— per-core unit; owns the task queue, aconcurrency_slot_pool m_slots, and theworker_elasticlist; carries the pool-widestd::atomic<std::size_t> &m_current_worker/&m_max_worker.core_elastic::adjust_workers— slot-gated dispatch loop (while !queue.empty && slots available).core_elastic::get_or_make_available_worker/reserve_available_worker(CAS onm_max_worker) /allocate_worker— worker reuse-or-spawn.core_elastic::get_task_and_slot_or_become_available— atomic fetch-task-and-slot or register worker idle.core_elastic::get_runtime_stats/has_queued_task/adjust_runtime_parameter.worker_elastic— the elastic worker;execute_current_task(moves slot intoentry::m_slotfor the task),get_new_task,run,get_retire_if_excess.cubthread::stats_t(enum class : bool { on, off }) /cubthread::pool_t(enum class : uint8_t { basic, elastic }) inthread_compat.hpp.worker_pool_type<Stats, Type>alias +thread_create_worker_pool<...> (Args&&...)(thread_manager.hpp) — replaces the oldworker_pool_type/stats_worker_pool_typetypedefs and the two inline factories.- Removed temporary-worker surface (CBRD-26683):
execute_on_core (task, core_hash, bool is_temp)→execute_on_core (task, core_hash);execute_task (task, bool is_temp)→execute_task (task);get_worker_count ()→get_pool_size ();push_task_on_coreloses itsmethod_modeargument.
concurrency slot + per-core slot pool (CBRD-26685 / 26689)
Section titled “concurrency slot + per-core slot pool (CBRD-26685 / 26689)”cubthread::concurrency_slot(concurrency_slot.hpp) — the token;m_owner_pool,m_holder_pool,m_wait,m_wait_since;reset,start_waiting,stop_waiting,has_wait_expired (now)(50 ms),return_to_pool.cubthread::concurrency_slot_pool : concurrency_slot_subscriber— per-core pool guarded by the core mutex;m_available_slots(queue),m_wait_queue(std::list<entry *>),m_slot_count,m_target_count,m_surplus,SLOT_SURPLUS_THRESHOLD = 2.concurrency_slot_pool::initialize/adjust_concurrency/try_acquire_slot/acquire_slot(blocking; nested-wait resume-status save/restore) /release_slot/return_slot/borrow_surplus_slots(2000 ms) /needs_slot/get_score/get_runtime_stats/check_surplus_slots/wakeup_workers/has_queued_task.
slot daemon + pub/sub (CBRD-26686 / 26691 / 26690)
Section titled “slot daemon + pub/sub (CBRD-26686 / 26691 / 26690)”cubthread::concurrency_slot_subscriber— a core pool registers itself onactivate (identifier)(identifier = owningworker_pool *).cubthread::concurrency_slot_publisher—traverse (func)overm_subscribersunderm_mutex.cubthread::concurrency_slot_daemon : concurrency_slot_publisher— singleton;initialize/finalize/get_publisher/create_daemon(50 mslooper, name"concurrency").concurrency_slot_daemon_task : entry_task(in.cpp) —execute=check_and_propagate_parameters→steal_from_entries_if_excess→steal_from_cores_if_excess→distribute_slots→wakeup_workers; plushas_slot_demand,tune_parameters(clamp[system_core_count(), CSS_MAX_CLIENT_COUNT], worker ≥ concurrency).
thread suspension integration (CBRD-26687)
Section titled “thread suspension integration (CBRD-26687)”thread_prepare_suspension/thread_prepare_resumption(thread_entry.cpp, static) — slot release/soft-wait on suspend, slot keep/reacquire on resume; called fromthread_suspend_wakeup_and_unlock_entryandthread_suspend_timeout_wakeup_and_unlock_entry.cubthread::entry::m_slot(std::unique_ptr<concurrency_slot>),entry::start_waiting/stop_waiting,event_stat::slot_waits(thread_entry.hpp).thread_resume_suspend_status::THREAD_CONCURRENCY_SLOT_SUSPENDED(25) /THREAD_CONCURRENCY_SLOT_RESUMED(26); labels added tothread_resume_status_to_string.thread_concurrency_slot_release/thread_concurrency_slot_acquire(thread_manager.hpp) — explicit release/acquire around the SP blocking receive inpl_execution_stack_context.hpp.net_server_wakeup_workers(network_sr.c) — acceptsTHREAD_CONCURRENCY_SLOT_SUSPENDED.
Phase 2 system parameters, stats, server wiring (CBRD-26688 / 26692 / 26977)
Section titled “Phase 2 system parameters, stats, server wiring (CBRD-26688 / 26692 / 26977)”PRM_ID_MAX_REQUEST_CONCURRENCY(max_request_concurrency) — new; defaultsystem_core_count () * 3;PRM_USER_CHANGE.PRM_ID_MAX_REQUEST_WORKER(max_request_worker) — renamed fromPRM_ID_TASK_WORKER;PRM_USER_CHANGE.PRM_REQUEST_WORKER_ELASTIC_HEADROOM(= 32);prm_tune_parametersrewrite deriving both from the client limit.PRM_ID_PAGE_LATCH_TIMEOUT→PRM_ID_PAGE_LATCH_TIMEOUT_IN_MSECS(renamed + ×1000 rescale; incidental to this PR).css_set_max_concurrency_and_workers/css_get_thread_runtime_stats(server_support.c) — runtime apply- peek-stats accessor;
css_Server_request_worker_poolretyped toworker_pool_type<stats_t::on, pool_t::elastic>;concurrency_slot_daemon::initialize/finalizeincss_init.
- peek-stats accessor;
PSTAT_REQUEST_CONCURRENCY_TOTAL/_TARGET/_BUSY,PSTAT_REQUEST_WORKER_TOTAL/_TARGET/_BUSY(perf_monitor.{c,h}) — statdumpNum_request_concurrency_*/Num_request_worker_*.metadata_of_job_queues(show_meta.c) /css_wp_core_job_scan_mapper(templated) —SHOW JOB QUEUES7-column layout.cubthread::system_core_count/cgroup::cpu::quota(cgroup.cpp,resources.cpp) — cgroup v1/v2 CPU-quota aware core count (CBRD-26977).
Position hints (as of 2026-04-30)
Section titled “Position hints (as of 2026-04-30)”| Symbol | File | Line |
|---|---|---|
cubsocket::epoll (class) | src/base/epoll.hpp | 42 |
cubsocket::epoll::epoll | src/base/epoll.cpp | 37 |
cubsocket::epoll::wait | src/base/epoll.cpp | 54 |
cubsocket::epoll::add_descriptor | src/base/epoll.cpp | 59 |
cubsocket::epoll::modify_descriptor | src/base/epoll.cpp | 80 |
cubsocket::epoll::remove_descriptor | src/base/epoll.cpp | 101 |
cubconn::connection::worker (class) | src/connection/connection_worker.hpp | 52 |
worker::message_type (enum) | src/connection/connection_worker.hpp | 106 |
worker::worker | src/connection/connection_worker.cpp | 75 |
worker::attach | src/connection/connection_worker.cpp | 2107 |
worker::initialize | src/connection/connection_worker.cpp | 1943 |
worker::finalize | src/connection/connection_worker.cpp | 1975 |
worker::run | src/connection/connection_worker.cpp | 2007 |
worker::enqueue | src/connection/connection_worker.cpp | 160 |
worker::notify | src/connection/connection_worker.cpp | 182 |
worker::enqueue_and_notify | src/connection/connection_worker.cpp | 218 |
worker::push_task_into_worker_pool | src/connection/connection_worker.cpp | 288 |
worker::purge_stale_contexts | src/connection/connection_worker.cpp | 294 |
worker::handle_connection_close | src/connection/connection_worker.cpp | 386 |
worker::statistics_metrics_to_coordinator | src/connection/connection_worker.cpp | 562 |
worker::hibernate_check | src/connection/connection_worker.cpp | 584 |
worker::ha_close_all_connections | src/connection/connection_worker.cpp | 606 |
worker::handle_message_queue_new_client | src/connection/connection_worker.cpp | 1016 |
worker::handle_message_queue_handoff_client | src/connection/connection_worker.cpp | 1079 |
worker::handle_message_queue_takeover_client | src/connection/connection_worker.cpp | 1160 |
worker::handle_message_queue_shutdown_client | src/connection/connection_worker.cpp | 1227 |
worker::handle_message_queue | src/connection/connection_worker.cpp | 1356 |
worker::handle_reception | src/connection/connection_worker.cpp | 1694 |
worker::handle_transmission | src/connection/connection_worker.cpp | 1782 |
worker::handle_exhausted_add_context | src/connection/connection_worker.cpp | 1837 |
worker::handle_exhausted | src/connection/connection_worker.cpp | 1854 |
cubconn::connection::pool (class) | src/connection/connection_pool.hpp | 39 |
pool::freelist | src/connection/connection_pool.hpp | 42 |
pool::initialize | src/connection/connection_pool.cpp | 62 |
pool::finalize | src/connection/connection_pool.cpp | 89 |
pool::dispatch | src/connection/connection_pool.cpp | 109 |
pool::claim_context | src/connection/connection_pool.cpp | 140 |
pool::retire_context | src/connection/connection_pool.cpp | 160 |
pool::initialize_freelist | src/connection/connection_pool.cpp | 213 |
pool::initialize_topology | src/connection/connection_pool.cpp | 249 |
pool::initialize_workers | src/connection/connection_pool.cpp | 269 |
pool::finalize_workers | src/connection/connection_pool.cpp | 314 |
pool::initialize_coordinator | src/connection/connection_pool.cpp | 353 |
pool::start_coordinator | src/connection/connection_pool.cpp | 376 |
cubconn::connection::coordinator (class) | src/connection/coordinator.hpp | 41 |
coordinator::coordinator | src/connection/coordinator.cpp | 57 |
coordinator::initialize | src/connection/coordinator.cpp | 1192 |
coordinator::run | src/connection/coordinator.cpp | 1240 |
coordinator::transfer_connection | src/connection/coordinator.cpp | 237 |
coordinator::scale_up | src/connection/coordinator.cpp | 281 |
coordinator::scale_down | src/connection/coordinator.cpp | 348 |
coordinator::scale_down_finish | src/connection/coordinator.cpp | 317 |
coordinator::scale_trial | src/connection/coordinator.cpp | 378 |
coordinator::scale_selection | src/connection/coordinator.cpp | 415 |
coordinator::statistics_find_score_extremes | src/connection/coordinator.cpp | 460 |
coordinator::statistics_update_score | src/connection/coordinator.cpp | 482 |
coordinator::statistics_update_connection | src/connection/coordinator.cpp | 502 |
coordinator::statistics_update_task | src/connection/coordinator.cpp | 545 |
coordinator::statistics_rebalancing | src/connection/coordinator.cpp | 586 |
coordinator::statistics_scaling | src/connection/coordinator.cpp | 629 |
coordinator::handle_message_queue_new_client | src/connection/coordinator.cpp | 934 |
coordinator::handle_message_queue_return_to_pool | src/connection/coordinator.cpp | 970 |
coordinator::handle_message_queue_handoff_reply | src/connection/coordinator.cpp | 992 |
coordinator::handle_message_queue_statistics | src/connection/coordinator.cpp | 1032 |
coordinator::handle_controller_request | src/connection/coordinator.cpp | 1110 |
cubconn::connection::context | src/connection/connection_context.hpp | 141 |
cubconn::statistics::metrics | src/connection/connection_statistics.hpp | 111 |
cubconn::connection::controller (template) | src/connection/controller.hpp | 43 |
cubthread::worker_pool | src/thread/thread_worker_pool.hpp | 54 |
cubthread::worker_pool_task_capper | src/thread/thread_worker_pool_taskcap.hpp | 30 |
css_push_server_task | src/connection/server_support.c | 2354 |
css_get_task_stats | src/connection/server_support.c | 2647 |
REGISTER_CONNECTION (macro) | src/thread/thread_manager.hpp | 496 |
PRM_ID_TCP_KEEPALIVE_IDLE (param row) | src/base/system_parameter.c | 5161 |
PRM_ID_TASK_WORKER (param row) | src/base/system_parameter.c | 5197 |
PRM_ID_CSS_MAX_CONNECTION_WORKER (param row) | src/base/system_parameter.c | 5209 |
PRM_ID_CSS_AUTO_SCALING_WINDOW_SIZE (param row) | src/base/system_parameter.c | 5243 |
PRM_ID_CSS_RECV_BUDGET_PER_CONNECTION (param row) | src/base/system_parameter.c | 5259 |
PRM_ID_CSS_SEND_BUDGET_PER_CONNECTION (param row) | src/base/system_parameter.c | 5271 |
Phase 2 position hints (PR #7323 — line numbers omitted until merge).
Because CBRD-26662 is still an open PR against develop and the
branch is not checked out locally, absolute line numbers are not
stable; the table below anchors Phase 2 symbols to their files only.
Refresh with line numbers when the branch merges.
| Symbol | File |
|---|---|
cubthread::worker_pool_elastic / core_elastic / worker_elastic | src/thread/thread_worker_pool_elastic.hpp |
cubthread::concurrency_slot / concurrency_slot_pool | src/thread/concurrency_slot.hpp |
concurrency_slot_daemon / concurrency_slot_daemon_task | src/thread/concurrency_slot.cpp |
stats_t / pool_t | src/thread/thread_compat.hpp |
worker_pool_type<Stats,Type> / thread_create_worker_pool / thread_concurrency_slot_release / thread_concurrency_slot_acquire | src/thread/thread_manager.hpp |
thread_prepare_suspension / thread_prepare_resumption | src/thread/thread_entry.cpp |
entry::m_slot / event_stat::slot_waits / THREAD_CONCURRENCY_SLOT_SUSPENDED / _RESUMED | src/thread/thread_entry.hpp |
css_set_max_concurrency_and_workers / css_get_thread_runtime_stats | src/connection/server_support.c |
PL slot release/acquire around receive_buffer | src/sp/pl_execution_stack_context.hpp |
PRM_ID_MAX_REQUEST_CONCURRENCY / PRM_ID_MAX_REQUEST_WORKER / PRM_REQUEST_WORKER_ELASTIC_HEADROOM | src/base/system_parameter.c |
PSTAT_REQUEST_CONCURRENCY_* / PSTAT_REQUEST_WORKER_* | src/base/perf_monitor.{c,h} |
cgroup::cpu::quota (v1/v2) | src/base/cgroup.cpp |
Cross-check Notes
Section titled “Cross-check Notes”Sibling doc — cubrid-thread-worker-pool.md. The legacy
doc describes (a) css_master_thread accept loop, (b) one
polling thread per accepted connection, (c) the
cubthread::worker_pool and its core::worker machinery,
(d) css_push_server_task as the dispatch point. Of those,
(c) and (d) are still live and current. (a) is unchanged at
the master-thread accept layer, but the handover point is
now pool::dispatch (forwarding NEW_CLIENT to the
coordinator) instead of “spawn a polling thread for this
fd”. (b) is replaced: any reference in the legacy doc to
“each connection has a thread” is no longer accurate.
Look-up symbols that moved domains:
- Polling/recv-loop logic in legacy was scattered across
per-connection threads driven by
css_internal_request_handler; now lives incubconn::connection::worker::handle_receptionand friends. - Connection-close protocol in legacy was a synchronous
css_close_socketfrom the polling thread; now isworker::handle_connection_closewith retry-via-LAZY-queue and a separate freelist return phase. - Stats in legacy were per-worker
cubperf::stat_valuearrays read with the worker pool’sget_stats; for the connection side, those readings no longer exist as counters at all (CBRD-26177 directive). Use the coordinator’s controller socket (SHOW_STATS) for diagnostics. - Admission control in legacy was
worker_pool_task_capperfor HA daemons only; in NG, every connection worker enforces a per-tick byte budget. The capper class is still in tree but is not on the connection-worker path.
Sibling doc — cubrid-server-session.md. Server session
state lookup happens during request processing inside the
task worker (after css_push_server_task lands in the
back-end pool). The connection worker does not look up
sessions; it only parses the network protocol. The
session_p field on css_conn_entry is read on the task
side (see css_server_task::execute in server_support.c).
This is unchanged from the legacy doc and the redesign does
not move it.
Regressions tracked under the EPIC.
- CBRD-26586 — parallel query uses only one CPU after worker
timeout. Root cause confirmed by Hong Yechan to be the
interaction between
thread_worker_timeout_secondsand affinity inheritance: when the connection worker creates a task worker (because the task pool let a thread expire), the new pthread inherits the connection worker’s CPU affinity, pinning all back-end work to the connection worker’s core. Fix: do not inherit affinity for newly-spawned task workers. Workaround until fix lands: setthread_worker_timeout_secondshigh so back-end threads are not recycled. - CBRD-26617 — task worker thread name inherits “connections”.
Same mechanism (attribute inheritance from the spawning
thread). Confusing in core dumps because the thread name is
used to label
core.<name>...files, so a task-worker crash producedcore.connections.*. Fix: set thread name when the task pool spawns a worker. - CBRD-26544 — schema_type_str synonym enum coredump. Pre-existing on develop; surfaced under the new build because CCI’s enum and its string array drifted out of sync. Fixed in the same merge window.
- CBRD-26523 — HA test cases cbrd_21506_02, cbrd_22705_02
fail. Diagnosed as a pre-existing HA timing bug
(logwr/copylog interaction on
tid:0system commits) that the redesign exposes because the new connection structure speeds up state transitions. Not a redesign regression; rerouted to CBRD-26576 for the actual fix.
HA-shell test set after merge. CBRD-26255 comments
record a separate batch of HA shell-test failures
(bug_bts_5212, bug_bts_9047, cbrd_22207, cbrd_23854,
etc.) all attributed to timing changes — the redesign
genuinely is faster, and that exposes test scripts whose
sleeps and grep filters were calibrated to the legacy speed.
The fixes were a mix of test-script timing tweaks and one
genuine bug (-353 Resource temporarily unavailable under
ulimit -n constraint, fixed by raising the FD limit and
documenting the new minimum).
The CBRD-26177 “no perfmon” directive. Repeated here because it is the most likely thing to be broken by a future contributor:
“connection worker는 매우 동시성이 높은 hot-path이므로 perfmon 계열의 모니터링 코드를 추가해서는 안된다. 심각한 성능 저하를 일으킬 수 있다.”
Practical implications when reading or editing the code:
- Do not add
perfmon_inc_stator any global atomic increment toworker::run,worker::handle_reception,worker::handle_transmission,worker::handle_packet, the message-queue handlers, or any of their callees. - Do add metrics to
statistics::metrics<>instances on the worker (they are privateuint64_t[]); the coordinator already sums them. - The controller socket (
SHOW_STATS) is the supported read-out path;statistics_printis the renderer. - Per-context counters belong on
context::m_stats, and their aggregation via the coordinator’sstatistics_update_connectionis already wired.
Phase 1 → Phase 2 status. Phase 1 (CBRD-26177) delivered “high
throughput at high concurrency” but exposed the follow-on weakness
CBRD-26636 measured: a lean back-end pool stalls when a few workers
enter lock waits. Phase 2 (CBRD-26662) closes that with the slot
mechanism documented above and is implemented in PR #7323
(feature/worker_pool_elastic), open against develop; per the
assignee only test cases remain before merge. Consequences for
anyone reading the tree:
- The canonical back-end knobs are now
max_request_concurrency(active-transaction cap) andmax_request_worker(thread overcommit cap), nottask_group/task_worker.task_workeris renamed away;task_groupsurvives only as a deprecated1-valued no-op so oldcubrid.conffiles still load. - Naming drift to watch for when cross-referencing tickets: the EPIC
says
high_concurrency, sub-task CBRD-26688 saysmax_transaction_concurrency/max_transaction_worker, but the shipped parameters aremax_request_concurrency/max_request_worker. CBRD-26688’s constraint text (worker <= concurrency) is inverted relative to the code (worker >= concurrency). - The lock manager, page-buffer, and CSS queue were not modified;
do not look there for the slot logic. It lives in
thread_entry.cpp(thread_prepare_suspension/thread_prepare_resumption) andconcurrency_slot.{hpp,cpp}. - PR #7323 also carries the CBRD-26977 hardening series (global worker
cap, cgroup-v1/v2-aware core count) and several other changes
that appear orthogonal to concurrency control — aggregate value-slot sharing in
xasl_generation.c, page-buffer fix/unfix counter sharding inpage_buffer.c, aconnection_*NET_HEADER refactor, and a flashback/CDC guard inlog_manager.c— none of which are part of the concurrency-slot mechanism (likely co-developed on the branch, though a few may have arrived via adevelopmerge).
Open Questions
Section titled “Open Questions”-
Affinity-aware connection placement. The coordinator picks the minimum-score worker. When a connection is
pgxc-style stateful (HA replication, CDC consumer, log-writer slave), is there value in pinning it to a fixed worker for the connection lifetime? The currenttransfer_connectionwill re-balance even long-lived sessions; the only opt-out isis_wait_requiredreturning false forcdc_Gl.conn.fdinworker::is_wait_required. A first-class “affinity-pinned connection” flag would close the gap. -
HA replication’s connection model. The connection worker honours
HA_SERVER_STATE_TO_BE_STANDBYby force-closing non-active contexts (ha_close_all_connections). What happens during the opposite transition (standby → master), when a fresh batch of clients reconnects en masse and the coordinator has to allocate many contexts in a burst? The freelist is sized tomax_connections * 1.1, so it should absorb the burst, but the coordinator is single-threaded onhandle_message_queue_new_client. Concrete bound on the new-connection rate the coordinator can sustain is not measured. -
Score-function weights. The macros
EVAL_WORKER (25, 3.5, …) + (500, 1, …)andEVAL_CONTEXT (50, 1000, …) + (10, 1, …)are tuned constants. CBRD-26424 acknowledged this is empirical. What is the sensitivity surface? Could a runtime-tunable weight set obviateauto_scaling_window_sizeby letting operators bias the score toward latency or throughput? -
Verification gap from CBRD-26421. The task explicitly stated that connection-worker rebalancing and dynamic scaling are not covered by automated tests because the connection pool’s internal state is not exposed through any user-visible interface. The controller socket is for debugging only. A read-only
SHOW STATSSQL or DBA-RPC view would close the test gap. Phase 2 partly answers this on the back-end side — theNum_request_concurrency_*/Num_request_worker_*statdump counters and the re-columnedSHOW JOB QUEUES(CBRD-26692) expose slot/worker occupancy — but the connection-worker (front-end) rebalancing state still has no user-visible view. -
std::nothrowvs. STL exceptions (CBRD-26412). The ticket’s resolution is essentially “we cannot guard exhaustively because STL throws and the codebase uses STL”. Some hot-path allocations (pool::freelist (32 * 1024),m_context.reserve (256),m_exhausted.reserve (128)) still throw on OOM. What’s the failure semantic the operator should expect — server crash, dropped connection, or graceful degradation? Today it is the first. -
Send/recv budget defaults. 16 KB / 32 KB are reasonable for OLTP but are likely small for bulk-load and CDC streaming. Is there a per-connection-class override path short of editing
cubrid.conf? -
Per-core slot balance assumption (CBRD-26685). The slot pool manages concurrency per core on the stated assumption that “tasks distribute evenly across cores”, so a per-core cap approximates a global cap. Phase 1 routes a task to a core by
conn_ref.idxhash, which is even in aggregate but not instantaneously — a burst of heavy tasks hashing to one core can locally exhaust its slots while peers idle. The slot daemon’s 2000 ms surplus-transfer smooths this over seconds; is that fast enough for latency-sensitive bursts, or should redistribution be demand-triggered rather than timer-triggered? -
Hard-wait threshold and surplus timings. The soft→hard wait promotion fires at a fixed 50 ms (
concurrency_slot::has_wait_expired) and the inter-core surplus transfer at 2000 ms withSLOT_SURPLUS_THRESHOLD = 2. These are compile-time constants. Under a workload whose typical lock wait sits just above 50 ms, the daemon will churn slots (steal, then the waiter reacquires moments later). Should the threshold adapt to the observed lock-wait distribution? -
Overcommit ceiling vs. thread cost.
max_request_workerdefaults tomax_request_concurrency * 2 + 32and is a soft, non-hard cap (“allow to make worker overcommit”). Each parked worker still owns acubthread::entryand a stack. What is the memory/scheduling cost at the ceiling on a many-core box, and is the+ 32headroom (PRM_REQUEST_WORKER_ELASTIC_HEADROOM) the right constant, or should it scale with core count? -
SP “execute up to some point without a slot” boundary (CBRD-26687). The ticket notes that for the SP path, reception itself is not tied to a code critical section, so a worker is allowed to run some way past the slot boundary before it must hold a slot again. Where exactly is that boundary drawn in
pl_execution_stack_context, and can it admit more thanmax_request_concurrencygenuinely-CPU-active workers in the SP case? -
Test-case status. CBRD-26662’s sub-tasks are all marked “regression test 로 검증 (verify by regression test after merge)”, and PR #7323 is open with test cases still pending. Until the regression suite lands, the concurrency invariant (“active transactions ≤
max_request_concurrency”) is asserted by design, not yet by an automated test.
Sources
Section titled “Sources”Source paths
Section titled “Source paths”src/connection/connection_worker.cpp(≈ 58 KB)src/connection/connection_worker.hpp(≈ 10 KB)src/connection/connection_pool.cpp(≈ 10 KB)src/connection/connection_pool.hpp(≈ 3 KB)src/connection/coordinator.cpp(≈ 35 KB)src/connection/coordinator.hpp(≈ 10 KB)src/connection/controller.hppsrc/connection/connection_context.hppsrc/connection/connection_statistics.hppsrc/connection/connection_support.{cpp,hpp}src/connection/server_support.c—css_push_server_task(line 2354),css_get_task_stats(line 2647)src/connection/tcp.c—setsockopt SO_KEEPALIVE(line 203)src/base/epoll.{cpp,hpp}src/thread/thread_worker_pool.hpp— abstract pool interface (line 54)src/thread/thread_worker_pool_impl.{cpp,hpp}— pool implementationsrc/thread/thread_worker_pool_taskcap.{cpp,hpp}— legacy admission capsrc/thread/thread_manager.hpp—REGISTER_CONNECTION(line 496)src/base/system_parameter.{c,h}— param IDs and rows fortcp_keepalive_*,task_group,task_worker,min/max_connection_worker,auto_scaling_window_size,recv/send_budget_per_connectionsrc/executables/server.c—cubconn::connection::pool connections;(line 557)
Phase 2 (CBRD-26662, PR #7323) — line numbers are diff-relative until the branch merges:
src/thread/concurrency_slot.hpp(+235) /concurrency_slot.cpp(+925) — slot, per-core slot pool, pub/sub, slot daemon.src/thread/thread_worker_pool_elastic.hpp(+831) —worker_pool_elastic/core_elastic/worker_elastic.src/thread/thread_worker_pool_impl.{hpp,cpp}— reworked base pool (available-worker list, snapshot guard,get_pool_size).src/thread/thread_worker_pool.hpp/thread_compat.hpp/thread_manager.{hpp,cpp}— interface,stats_t/pool_t, theworker_pool_type<>alias + factory,thread_concurrency_slot_*.src/thread/thread_entry.{cpp,hpp}—thread_prepare_suspension/_resumption,m_slot,slot_waits, new resume statuses.src/communication/network_sr.c— wake-eligible switch.src/sp/pl_execution_stack_context.hpp— SP slot release/acquire.src/base/system_parameter.{c,h}—max_request_concurrency/max_request_worker,task_groupdeprecation,prm_tune_parameters.src/base/perf_monitor.{c,h}—Num_request_*peek stats.src/base/cgroup.{cpp,hpp}/resources.cpp— cgroup-aware core count (CBRD-26977).src/parser/show_meta.c+src/connection/server_support.c—SHOW JOB QUEUES7-column layout;css_set_max_concurrency_and_workers,css_get_thread_runtime_stats.- Basic-pool migrations to the new alias:
src/query/vacuum.c,src/loaddb/load_worker_manager.cpp,src/storage/btree_load.c,src/transaction/log_page_buffer.c,src/query/parallel/px_worker_manager_global.hpp,src/transaction/log_recovery_redo_parallel.hpp.
JIRA tickets
Section titled “JIRA tickets”- EPIC: http://jira.cubrid.org/browse/CBRD-26177
- Survey: http://jira.cubrid.org/browse/CBRD-26152
- POC: http://jira.cubrid.org/browse/CBRD-26212
- Pool redesign: http://jira.cubrid.org/browse/CBRD-26255
- Send/recv budgets: http://jira.cubrid.org/browse/CBRD-26392
- Rebalancing + auto scaling: http://jira.cubrid.org/browse/CBRD-26406
- Coordinator + freelist: http://jira.cubrid.org/browse/CBRD-26407
- Null-guard for
new: http://jira.cubrid.org/browse/CBRD-26412 - Verification cases (postponed): http://jira.cubrid.org/browse/CBRD-26421
- Score formula: http://jira.cubrid.org/browse/CBRD-26424
- HA bugs: http://jira.cubrid.org/browse/CBRD-26523
- Synonym-enum coredump: http://jira.cubrid.org/browse/CBRD-26544
- Parallel-query CPU regression: http://jira.cubrid.org/browse/CBRD-26586
- Thread-name inheritance: http://jira.cubrid.org/browse/CBRD-26617
- Worker-count sweep: http://jira.cubrid.org/browse/CBRD-26636
- Atomic-free monitoring: http://jira.cubrid.org/browse/CBRD-26191
- HA timing bug spinoff (from CBRD-26523): http://jira.cubrid.org/browse/CBRD-26576
- Manual update: http://jira.cubrid.org/browse/CUBRIDMAN-333
Phase 2 (Logical-Wait-Aware Concurrency Control):
- EPIC: http://jira.cubrid.org/browse/CBRD-26662
- Implementation PR: https://github.com/CUBRID/cubrid/pull/7323
(
feature/worker_pool_elastic→develop; open, tests pending) - Remove temp worker: http://jira.cubrid.org/browse/CBRD-26683
- Elastic worker pool: http://jira.cubrid.org/browse/CBRD-26684
- Slot pool interface: http://jira.cubrid.org/browse/CBRD-26685
- Slot daemon interface: http://jira.cubrid.org/browse/CBRD-26686
- Slot pool callers (lock/SP): http://jira.cubrid.org/browse/CBRD-26687
- Parameters: http://jira.cubrid.org/browse/CBRD-26688
- Slot pool implementation: http://jira.cubrid.org/browse/CBRD-26689
- Runtime parameter apply: http://jira.cubrid.org/browse/CBRD-26690
- Slot daemon implementation: http://jira.cubrid.org/browse/CBRD-26691
- statdump stats: http://jira.cubrid.org/browse/CBRD-26692
- Global worker cap + cgroup awareness: http://jira.cubrid.org/browse/CBRD-26977
Textbook references
Section titled “Textbook references”- Silberschatz, Korth, Sudarshan. Database System Concepts, 6th ed. — Ch. 13 “Storage and File Structure” (buffer basics, framing of front-end vs back-end).
- Petrov, Alex. Database Internals (O’Reilly, 2019). §5.3 “Concurrent Execution” — pool sizing intuition, C10K framing.
- Stevens, W. Richard. UNIX Network Programming, Vol. 1, 3rd ed. — §16.5 “TCP Concurrent Server, One Child per Client” (the model the redesign moves away from).
- Pai, V., P. Druschel, W. Zwaenepoel. Flash: An Efficient and Portable Web Server. USENIX 1999. (event-driven asymmetric multi-process design — direct ancestor of the reactor pattern in this redesign).
- Welsh, M., D. Culler, E. Brewer. SEDA: An Architecture
for Well-Conditioned, Scalable Internet Services. SOSP
2001. (admission control via bounded stage queues — the
intellectual basis for
recv_budget_per_connection/send_budget_per_connection). - Linux kernel docs —
epoll(7),eventfd(2),timerfd_create(2). TheEPOLLET(edge-triggered) semantics are mandatory background reading for anyone modifyingworker::run. - Silberschatz, Korth, Sudarshan. Database System Concepts — Ch. 18 “Concurrency Control” (lock waits as the dominant non-CPU component of transaction service time — the framing for Phase 2’s slot admission).
- MySQL Reference Manual — “MySQL Enterprise Thread Pool” and the thread-group stall detection mechanism (the closest prior art: cap active threads, overcommit on stall — Phase 2 adopts the shape but tokenises it as a slot).
- Microsoft SQL Server docs — “Thread and Task Architecture Guide” (SOS scheduler, cooperative yielding at defined points — the yield-your-entitlement analogue to slot hand-off).
- Linux kernel docs —
cgroups(7)(cpu.max/cpu.cfs_quota_us) — the CPU-quota sourcesystem_core_count()reads under CBRD-26977 so defaults track a container’s limit.