Compare commits

..
180 Commits
Author SHA1 Message Date
andrew 3b7cc2a70f Don't count_down past 0
CI / pre-commit (push) Successful in 59s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64) (push) Successful in 2m22s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64) (push) Successful in 2m57s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64) (push) Successful in 2m44s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64) (push) Successful in 3m10s
2026-07-17 21:15:23 -04:00
andrew a427278cc0 Read until EAGAIN
To prepare for EPOLLET
2026-07-17 15:39:56 -04:00
andrew 8056da856f Replace protocol_context void* with ProtocolHandle int64_t
CI / pre-commit (push) Successful in 58s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64) (push) Successful in 2m23s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64) (push) Successful in 2m59s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64) (push) Successful in 2m44s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64) (push) Successful in 3m12s
Use an opaque int64_t handle for correlating async responses instead of raw pointers. HTTP uses sequence_id as the handle and stores response data in a unique_ptr<HttpResponseContext> keyed by sequence_id.
2026-07-17 15:11:02 -04:00
andrew 0c0f154390 Rename append_message to send_response and update Connection construction docs 2026-07-17 15:11:02 -04:00
andrew addef07866 Prepare for more per io thread/epoll instance state 2026-07-17 15:11:02 -04:00
andrew 36a50dfdde Remove dead code 2026-07-17 15:11:02 -04:00
weaselbot a053b92911 Add basic Gitea Actions CI workflow (#6)
CI / pre-commit (push) Successful in 56s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64) (push) Successful in 2m24s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64) (push) Successful in 2m53s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64) (push) Successful in 2m44s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64) (push) Successful in 3m9s
Closes #5

Adds a Gitea Actions workflow that runs pre-commit checks and builds/tests the project on clang and gcc for both amd64 and arm64.

weaseljson is checked out, built, and installed locally because weaseldb depends on it via `find_package(weaseljson REQUIRED)`.

Also switches the llhttp FetchContent declaration from a tarball URL to a git repository pinned to the same release commit. The tarball redirect goes through codeload.github.com, which can be blocked in restricted network environments; using git keeps the fetch on github.com.

Reviewed-on: #6
Co-authored-by: Weaselbot <weaselbot@weaselab.dev>
Co-committed-by: Weaselbot <weaselbot@weaselab.dev>
2026-06-30 20:36:37 +00:00
andrew 6219592620 Listen on public interface for test benchmark 2026-06-26 19:03:15 -04:00
andrew edfa71ce7c Add script to reproduce threading performance report results
Adds `reproduce_threading_report.sh` to run the WeaselDB server and
load tester configuration described in `threading_performance_report.md`.

Improvements over the original draft:
- Validates the build directory, config file, and required binaries
  before starting, and expects to be run from the project root.
- Wraps server shutdown and log printing in an EXIT/INT/TERM trap
  so the server is always cleaned up, even if the script is
  interrupted or the load tester fails.
- Makes the `ulimit -n` increase best-effort instead of fatal.
- Uses the existing `DURATION` variable consistently in the load
  tester invocation.
- Adds a final check that the unix socket was created before
  launching the load tester.
- Uses 2 connect threads on the client, which is sufficient for
  establishing 2000 connections over the 30-second run.
2026-06-26 14:28:05 -04:00
andrew 5790603e31 Update threading performance report with reproduced numbers
The previous report claimed 1.0M req/s at 740ns serial CPU work for the /ok health check endpoint. That measurement was made with an earlier design that transferred unique ownership of connections through the pipeline.

The current server-owned connection model adds per-request synchronization overhead (mutex + WeakRef + pending response queue) that lowers the raw /ok throughput. Reproducing on an AMD Ryzen 9 7900 with the current Release build gives approximately 825k sustained req/s with the same 740ns serial CPU work.

Updated the report to reflect the reproduced numbers and added a note explaining the historical context and why the ownership model changed (to support streaming endpoints like /v1/subscribe and safer async responses).
2026-06-26 13:16:48 -04:00
andrew 273f288020 Merge pull request 'Support building on ARM with NEON histogram intrinsics' (#4) from weaselbot/weaseldb:weaselbot/issue-3 into main
Reviewed-on: #4
Reviewed-by: andrew <andrew@weaselab.dev>
2026-06-26 16:13:00 +00:00
weaselbot 14f8552906 Apply clang-format to ARM histogram code
Pre-commit's clang-format hook reformatted the AArch64 NEON histogram
bucket updates so the project style checks pass. No functional change.
2026-06-26 11:12:10 -04:00
weaselbot c71bdf13c4 Align AArch64 histogram parameter formatting with project style
Minor whitespace fix so the NEON function signature matches the existing
AVX function's indentation.
2026-06-26 11:09:08 -04:00
weaselbot a0d64afd6f Fix ARM64 assembly size directive in cpu_work.cpp
The GNU assembler expects `.size symbol, .-symbol`.  The previous
`.size spend_cpu_cycles, spend_cpu_cycles` expression is not a constant
and breaks compilation on AArch64 Linux.  Use the correct form so the
project builds on ARM64.
2026-06-26 11:08:20 -04:00
weaselbot a377772e63 Use AArch64 NEON intrinsics for histogram bucket updates
Replace the scalar ARM fallback in update_histogram_buckets with a NEON
implementation that processes two buckets per iteration, matching the
existing AVX path.  The wrapper now dispatches to the SIMD path on both
x86-64 and AArch64 and falls back to scalar code on other architectures.
2026-06-26 11:08:03 -04:00
weaselbot d7de96ef94 Support building on ARM by providing scalar histogram fallback
The metrics histogram update code unconditionally included <immintrin.h>
and used __attribute__((target("avx"))) SSE/AVX intrinsics, which only
exist on x86-64. This prevented the project from compiling on ARM64.

Guard the x86-64 SIMD implementation and the <immintrin.h> include with
an architecture check, and add a portable scalar fallback for non-x86-64
platforms (e.g., ARM64). A thin wrapper function keeps the call sites
unchanged and preserves the AVX fast path on x86-64.

Closes #3
2026-06-26 10:49:53 -04:00
andrew f458c6b249 Make pipeline policy/topology configurable 2025-11-06 15:55:27 -05:00
andrew 9f8562e30f Fix histogram thread death bug 2025-09-18 12:39:47 -04:00
andrew cb66d65479 Add a bit more precision to docs, plus philosophy 2025-09-16 00:57:20 -04:00
andrew 4d015fa3dc Make recording metrics never block 2025-09-15 23:34:30 -04:00
andrew 0659319906 Prepare for try_lock optimization
So histogram observations never block
2025-09-15 23:04:54 -04:00
andrew 6f421629aa Add comments to avoid blocking in sequence and resolve stages 2025-09-15 22:51:41 -04:00
andrew ba59a992dd Document per-connection locking strategy 2025-09-15 22:33:52 -04:00
andrew 0d76c73077 Only shut down write side for http 2025-09-15 21:42:36 -04:00
andrew 4ecbc07367 Fix flaky connection shutdown test 2025-09-15 21:39:48 -04:00
andrew 5e625197aa Fix url accumulation bug 2025-09-15 21:36:49 -04:00
andrew 5dda7353fa Add test for url accumulation bug 2025-09-15 21:36:49 -04:00
andrew 345d8e21b2 Use WaitIfUpstreamIdle 2025-09-15 20:48:12 -04:00
andrew 917066d8c0 Move thread local state into stack 2025-09-15 20:33:45 -04:00
andrew 5a88047b9f Two release threads 2025-09-15 20:09:15 -04:00
andrew 1acdc1e753 Put Arena destructor and move constructors in header 2025-09-15 17:01:38 -04:00
andrew ae0c014298 Don't clear write interest if pending_response_queue_ non empty 2025-09-15 16:35:37 -04:00
andrew e2115152c8 Add tests for shutdown vs close 2025-09-15 15:48:10 -04:00
andrew 55f6ebc02b Implement shutting down the write-side only 2025-09-15 15:39:28 -04:00
andrew 6b52c4289c Prevent queueing of messages on connection after it will be closed 2025-09-15 15:25:40 -04:00
andrew 7ee5ca2a9b Remove dead code, use proper send_ordered_response
And prepare to try to close a connection gracefully
2025-09-15 15:08:19 -04:00
andrew 9120c05847 Use spend_cpu_cycles instead of volatile loop 2025-09-15 14:07:38 -04:00
andrew 528a467518 Make test names match binary names 2025-09-15 13:06:15 -04:00
andrew a67d7a8531 Consolidate into one send_ordered_response 2025-09-15 13:01:56 -04:00
andrew 6717b70772 Remove HttpConnectionState::response_queue_mutex 2025-09-15 12:32:19 -04:00
andrew 34accb9d80 Move GetVersion to commit pipeline 2025-09-15 12:30:02 -04:00
andrew f3c3f77a24 Extract commit pipeline to its own module 2025-09-15 11:51:01 -04:00
andrew afd240dba7 Remove vestigial "round-robin" code 2025-09-15 11:22:14 -04:00
andrew 1cb7a4c301 Remove has_pending_responses_ 2025-09-15 11:10:47 -04:00
andrew 1b220d0d1c WIP 2025-09-15 10:28:17 -04:00
andrew ec2ad27e33 Add explanatory comments 2025-09-15 00:07:44 -04:00
andrew eb98e51867 We expect to get valid fds to close in ~Connection in ~Server 2025-09-15 00:07:09 -04:00
andrew 022a79bf5b Separate HttpRequestState and HttpConnectionState
Now HttpConnectionState has a queue of HttpRequestState
2025-09-14 23:49:32 -04:00
andrew fac6b8de88 Add test that shows parsing issue
It's meant to show the pipelining issue. I guess we'll solve the
newly-discovered parsing issue first.
2025-09-14 22:24:02 -04:00
andrew 1f61f91bf5 Reset connection state after finishing with it in http_handler 2025-09-14 21:16:41 -04:00
andrew 632113f792 Add test for pipeline request parsing bug 2025-09-14 20:53:19 -04:00
andrew f62770c4ab Add copying utility methods to Arena 2025-09-14 20:38:54 -04:00
andrew 147edf5c93 More cleanup 2025-09-14 20:27:14 -04:00
andrew f39149d516 Update documentation with new networking model 2025-09-14 19:03:56 -04:00
andrew 0389fd2c9f Consistently use state->arena for http handling 2025-09-14 17:16:05 -04:00
andrew 7ef54a2d08 Call epoll_ctl in release stage 2025-09-14 16:28:12 -04:00
andrew 16c7ee0408 Separate Connection and Request lifetimes 2025-09-14 15:04:37 -04:00
andrew cf0c1b7cc2 Add echo test for server 2025-09-14 12:56:22 -04:00
andrew bd06798fd3 Remove test_http_handler and test_server_connection_return 2025-09-14 11:38:43 -04:00
andrew e96a493835 Remove release_back_to_server 2025-09-14 09:03:05 -04:00
andrew e887906da8 Remove some unused/indirectly used headers 2025-09-13 17:25:46 -04:00
andrew de6f38694f std::unique_ptr<Connection> -> Ref<Connection> 2025-09-13 17:25:46 -04:00
andrew 1fa3381e4b Use send/sendmsg and don't ignore SIGPIPE 2025-09-13 17:25:20 -04:00
andrew cd2e15677a Remove epoll instances config 2025-09-12 18:05:07 -04:00
andrew 2b8f095d27 Fix minor issues 2025-09-12 12:13:50 -04:00
andrew 543447971f Fix polymorphic WeakRef bug 2025-09-12 12:08:46 -04:00
andrew f89868058a Require explicit copies for Ref/WeakRef 2025-09-12 11:59:56 -04:00
andrew 674ff581e7 Update comments/docs to match code 2025-09-12 11:40:38 -04:00
andrew be5a0c6d8e Update some inaccuracies in markdown files 2025-09-12 11:31:22 -04:00
andrew bf90b8856a Add mdformat pre-commit hook 2025-09-12 11:24:16 -04:00
andrew 9d48caca76 add end-of-file-fixer 2025-09-12 11:21:00 -04:00
andrew 0561d951d4 Finish std::shared_ptr -> Ref migration 2025-09-11 15:06:04 -04:00
andrew a2da7fba84 Explicitly support having a WeakRef to self 2025-09-11 14:54:42 -04:00
andrew 5d932bf36c Add polymorphism support to Ref 2025-09-11 14:15:52 -04:00
andrew 9a8d4feedd Add documentation 2025-09-11 13:54:00 -04:00
andrew 9cd83fc426 Call ~ControlBlock
It's trivially destructible, but just in case. Compiler should optimize it out
2025-09-11 13:18:19 -04:00
andrew 10e382f633 Used biased weak count, cache T* pointer
Logically, the strong pointer that destroys T owns +1 weak count too
2025-09-11 13:15:03 -04:00
andrew f83e21b5a0 Defeat shared_ptr's single-threaded optimizations
WeaselDB is always going to start multiple threads, so we don't care
about single-threaded performance
2025-09-11 13:13:05 -04:00
andrew 5adbf8eee2 Organize bench_reference.cpp with doctest 2025-09-11 12:32:25 -04:00
andrew 2bc17cbfe6 Add bench_reference.cpp
Also update snake case script for nanobench symbols
2025-09-11 12:22:56 -04:00
andrew 89c5a2f165 Strengthen language instructing reading the style guide 2025-09-11 12:02:44 -04:00
andrew d35a4fa4db Update multi-threaded tests/benchmarks guidance 2025-09-11 12:01:18 -04:00
andrew 994e31032f Fix data race in freeing control block 2025-09-11 11:32:59 -04:00
andrew 0f179eed88 Switch to two separate atomic counters
It's faster and still correct. I was confused remembering something
about atomic shared pointer ideas before.
2025-09-11 10:53:25 -04:00
andrew b9106a0d3c Add test_reference.cpp 2025-09-10 22:05:31 -04:00
andrew 6aaca4c171 Finish reference.hpp 2025-09-10 21:58:08 -04:00
andrew 7c4d928807 Start on Ref/WeakRef 2025-09-10 20:04:32 -04:00
andrew 5d289ddd42 Add metric for write EAGAIN failures 2025-09-10 16:48:27 -04:00
andrew 962a010724 Simplify process_connection_writes condition
And comment explaining that we there's something more precise but more
complex available.
2025-09-10 16:45:04 -04:00
andrew f56ed2bfbe Rename ArenaAllocator -> Arena 2025-09-05 17:57:04 -04:00
andrew 46fe51c0bb Make config.toml comments more descriptive and accessible 2025-09-05 16:36:50 -04:00
andrew b93cc2072a Remove -Wno-vla-cxx-extension from .clangd 2025-09-05 16:29:12 -04:00
andrew 0357a41dd8 Implement spend_cpu_cycles in assembly
The compiler was unrolling it previously, so we're doing assembly now for consistency.
2025-09-05 15:16:49 -04:00
andrew ffe7ab0a3e Update default in config.toml 2025-09-05 13:06:10 -04:00
andrew ed3cf25936 Update stale documentation 2025-09-05 13:04:34 -04:00
andrew e67e4aee17 Update /ok to serve dual health check/benchmarking role 2025-09-05 12:39:10 -04:00
andrew 761eaa552b Add -Wno-deprecated-literal-operator for clang 2025-09-05 11:39:04 -04:00
andrew e846bc49f6 Set rapidjson docs + examples to off 2025-09-05 11:29:08 -04:00
andrew 72481be46d Consolidate into two static libs - one with assertions and one without 2025-09-05 11:22:04 -04:00
andrew d04705624a Handle percent encoding 2025-09-04 20:47:58 -04:00
andrew 2278694f4f Separate out api url parser 2025-09-04 16:39:19 -04:00
andrew 55069c0c79 Add counters for /v1/{commit,status,version} 2025-09-04 15:49:54 -04:00
andrew 96aae52853 Basic implementation of /commit, /version, and /status
No precondition checking, persistence, or log scanning yet.
2025-09-04 15:40:17 -04:00
andrew 8b6736127a Add commit pipeline design 2025-09-04 13:40:03 -04:00
andrew 9272048108 Outline commit pipeline 2025-09-03 23:43:03 -04:00
andrew b2ffe3bfab Refactor to use format for http responses 2025-09-03 22:45:59 -04:00
andrew 978861c430 Parse commit request 2025-09-03 21:53:04 -04:00
andrew 46edb7cd26 Allow listening on multiple interfaces 2025-09-03 16:09:16 -04:00
andrew b8eb00e313 Wrap up metrics library 2025-09-03 15:43:26 -04:00
andrew 18b0a642bf Round out process collector 2025-09-03 15:34:55 -04:00
andrew f0916d8269 Add process collector 2025-09-03 14:38:10 -04:00
andrew 2fa5b3e960 Instrument connections 2025-09-03 13:57:23 -04:00
andrew 6d480487da Use temp_arena for formatting instead of cached plan arena 2025-09-03 13:23:58 -04:00
andrew 54d06c654f Move PerThreadState to per-thread arenas 2025-09-03 13:16:35 -04:00
andrew f067f4e85b Add weaseldb_metrics_memory_bytes 2025-09-03 13:06:34 -04:00
andrew 0ac4c31a53 Measure per metric in render scale bench 2025-09-03 12:51:49 -04:00
andrew 52b0cb3e6e Remove background thread from callback bench 2025-09-03 12:19:10 -04:00
andrew 76193f772c Tinker with benchmarks. Looking at render performance 2025-09-03 12:16:56 -04:00
andrew 0e4c526094 Fix realloc bug in static_format 2025-09-03 12:16:02 -04:00
andrew f16cff9126 Don't copy static_text in render 2025-09-03 11:54:35 -04:00
andrew 13e4039ed6 Add performance note to header
Also improve implementation comments
2025-09-03 11:18:03 -04:00
andrew 17efcf318e Fix potential alignment issue and add more implementation comments 2025-09-03 11:12:01 -04:00
andrew b3e48b904a Add some clarifying implementation comments 2025-09-03 11:01:01 -04:00
andrew 721f814785 Cache RenderPlan 2025-09-03 10:53:11 -04:00
andrew 8763daca8e Add arena to RenderPlan 2025-09-03 10:43:11 -04:00
andrew a30020e960 Add Metric::registration_version
For cache invalidation
2025-09-03 10:18:19 -04:00
andrew 1cd34ef4a9 Fix memory leak 2025-09-02 17:59:51 -04:00
andrew 0583a63649 WIP separate phases. Passes but has a memory leak 2025-09-02 17:51:41 -04:00
andrew 08fa1f311d Use prometheus text format as LabelsKey representation 2025-09-02 15:54:31 -04:00
andrew 4f1dcc54d9 Replace some ArenaVector's with std::span 2025-09-02 15:43:42 -04:00
andrew d43e6c2be5 Fix final placement new in metric.cpp 2025-09-02 15:31:57 -04:00
andrew 7f562f8116 Don't null-terminate 2025-09-02 15:28:32 -04:00
andrew 96eb8e8b0b Fix memory leaks 2025-09-02 15:25:38 -04:00
andrew 7006012aeb Fix stack-use-after-scope 2025-09-02 13:01:02 -04:00
andrew 3d573694c4 Add ArenaAllocator::Ptr 2025-09-02 12:13:00 -04:00
andrew 87bbb47787 More precompute 2025-09-01 17:50:34 -04:00
andrew d502f66bb4 Allocate memory up front for histogram copy 2025-09-01 17:06:08 -04:00
andrew 31e751fe75 Change iteration order to avoid temporary map 2025-09-01 16:52:40 -04:00
andrew 953ec3ad43 Separate compute and format phases for render 2025-09-01 15:05:27 -04:00
andrew 8326c67b9c Deterministic render ordering 2025-08-31 22:55:01 -04:00
andrew 8b828be0a9 Advise to cache Metric instance 2025-08-31 14:43:34 -04:00
andrew 58649103e5 Improve metric.hpp documentation 2025-08-31 14:39:24 -04:00
andrew b6809d8700 Add arena usage documentation 2025-08-31 14:20:29 -04:00
andrew 889109f4ae Intern label sets 2025-08-31 12:47:35 -04:00
andrew 4b2c5b8ce8 Accept initializer_list, span, and string_view in api 2025-08-31 12:31:29 -04:00
andrew 93ccd2eb71 Use Arena's to manage Metric memory where appropriate 2025-08-31 11:54:17 -04:00
andrew b52d6e5a13 Explain thread safety in Counter::inc 2025-08-30 19:14:16 -04:00
andrew f560ac1736 Use snake_case 2025-08-30 18:59:44 -04:00
andrew 4f72840e51 Integrate render into /metrics handler 2025-08-30 18:28:44 -04:00
andrew ff7642195b Make benchmark metric families global 2025-08-30 17:59:24 -04:00
andrew 0ff197d406 Fix static initialization order fiasco 2025-08-30 17:57:16 -04:00
andrew affeeb674a Clarify threading model for metrics 2025-08-30 17:29:39 -04:00
andrew 21ddcb75fb Fix thread destroy bug 2025-08-30 16:20:35 -04:00
andrew dcf8af6d43 Add test demonstrating thread destruction bug 2025-08-30 15:45:44 -04:00
andrew 935bab9454 Make histograms atomic
E.g. count and sum should be consistent with each other
2025-08-29 21:05:51 -04:00
andrew 50e27cced8 Add more TODOs 2025-08-29 17:15:36 -04:00
andrew d2762dc8da Add TODO 2025-08-29 17:05:51 -04:00
andrew 5592d065de Actually have contention in benchmark 2025-08-29 17:05:21 -04:00
andrew 91e799aae8 Use plain arrays and atomic read with intrinsics for render 2025-08-29 15:10:10 -04:00
andrew 4fc277393e Use std::latch sync in benchmarks too 2025-08-29 14:29:15 -04:00
andrew a5776004de Update potential misunderstanding about thread safety 2025-08-29 14:08:41 -04:00
andrew 62b37c067c Metrics implementation, WIP 2025-08-29 13:43:03 -04:00
andrew fac0d20ae1 Finish metrics design, I think 2025-08-29 11:51:40 -04:00
andrew e3a2ddbbfb Validation + callback api 2025-08-29 11:31:06 -04:00
andrew b6d4ae2862 Initialize atomics in metrics, update style guide on atomics 2025-08-29 10:52:26 -04:00
andrew 1133d1e365 Use std::bit_cast, document that gauge mutex is an implementation detail 2025-08-29 10:45:19 -04:00
andrew de5adb54d2 Flesh out metrics architecture more 2025-08-29 10:40:19 -04:00
andrew d0f2b6550a More scaffolding 2025-08-28 17:32:34 -04:00
andrew ca5b299da8 Make MetricKey hashable 2025-08-28 17:10:56 -04:00
andrew 9c89eba6c8 Metrics system scaffold 2025-08-28 17:04:53 -04:00
andrew ed6e6ea9fe Output trailing : for konsole integration workaround 2025-08-28 14:45:40 -04:00
andrew c97920c473 format utility improvements 2025-08-28 14:40:01 -04:00
andrew 7808896226 Add format benchmarks 2025-08-28 14:20:27 -04:00
andrew 404b491880 Add documentation 2025-08-28 14:05:45 -04:00
andrew bc0d5a7422 Add format utility 2025-08-28 14:01:43 -04:00
andrew 6fb57619c5 Remove inaccurate "zero-{copy,allocation}" claims 2025-08-28 13:40:05 -04:00
andrew f46a98249f Change to loop_iterations 2025-08-28 13:34:52 -04:00
andrew a73a463936 Fix Arena realloc bug 2025-08-28 13:27:53 -04:00
andrew a32356e298 Add ArenaVector 2025-08-28 13:27:21 -04:00
andrew 3d61408976 Use precise memory orderings in load_tester 2025-08-27 18:13:28 -04:00
76 changed files with 12723 additions and 2212 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
CompileFlags:
Add: [-Wno-vla-cxx-extension, -UNDEBUG]
Add: [-UNDEBUG]
+69
View File
@@ -0,0 +1,69 @@
name: CI
on: [push, pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest-amd64
steps:
- uses: actions/checkout@v4
- name: Install pre-commit
run: pipx install pre-commit
- name: Run pre-commit
run: ~/.local/bin/pre-commit run --all-files --show-diff-on-failure
build:
strategy:
fail-fast: false
matrix:
include:
- name: clang-amd64
runner: ubuntu-latest-amd64
cmake_args: -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
- name: clang-arm64
runner: ubuntu-latest-arm64
cmake_args: -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
- name: gcc-amd64
runner: ubuntu-latest-amd64
cmake_args: -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
- name: gcc-arm64
runner: ubuntu-latest-arm64
cmake_args: -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
path: weaseldb
- name: Checkout weaseljson
uses: actions/checkout@v4
with:
repository: weaselab/weaseljson
path: weaseljson
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y build-essential clang cmake gperf
- name: Build and install weaseljson
run: |
cmake -S weaseljson -B weaseljson/build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$(pwd)/weaseljson/install"
cmake --build weaseljson/build -j "$(nproc)"
cmake --install weaseljson/build
- name: Build weaseldb
run: |
cmake -S weaseldb -B weaseldb/build ${{ matrix.cmake_args }} \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_PREFIX_PATH="$(pwd)/weaseljson/install"
cmake --build weaseldb/build -j "$(nproc)"
- name: Test
run: |
cd weaseldb/build
ctest --output-on-failure -j "$(nproc)" --timeout 90
+8 -1
View File
@@ -3,11 +3,13 @@ repos:
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
exclude: ".*third_party/.*"
- id: check-added-large-files
- id: check-merge-conflict
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: 182152eb8c5ce1cf5299b956b04392c86bd8a126 # frozen: v20.1.8
rev: 86fdcc9bd34d6afbbd29358b97436c8ffe3aa3b2 # frozen: v21.1.0
hooks:
- id: clang-format
exclude: ".*third_party/.*"
@@ -23,6 +25,11 @@ repos:
- id: black
language_version: python3
- repo: https://github.com/executablebooks/mdformat
rev: ff29be1a1ba8029d9375882aa2c812b62112a593 # frozen: 0.7.22
hooks:
- id: mdformat
- repo: local
hooks:
- id: snake-case-enforcement
+148 -105
View File
@@ -49,6 +49,12 @@ FetchContent_MakeAvailable(nlohmann_json)
set(RAPIDJSON_BUILD_TESTS
OFF
CACHE BOOL "Disable RapidJSON tests" FORCE)
set(RAPIDJSON_BUILD_DOC
OFF
CACHE BOOL "Disable RapidJSON documentation" FORCE)
set(RAPIDJSON_BUILD_EXAMPLES
OFF
CACHE BOOL "Disable RapidJSON examples" FORCE)
FetchContent_Declare(
RapidJSON
GIT_REPOSITORY https://github.com/Tencent/rapidjson.git
@@ -74,10 +80,9 @@ FetchContent_MakeAvailable(simdutf)
FetchContent_Declare(
llhttp
URL "https://github.com/nodejs/llhttp/archive/refs/tags/release/v9.2.1.tar.gz"
URL_HASH
SHA256=3c163891446e529604b590f9ad097b2e98b5ef7e4d3ddcf1cf98b62ca668f23e
DOWNLOAD_EXTRACT_TIMESTAMP ON)
GIT_REPOSITORY https://github.com/nodejs/llhttp.git
GIT_TAG 610a87d755f6bae466cd871c2ba97574ccac5483 # release/v9.2.1
)
set(BUILD_SHARED_LIBS
OFF
CACHE INTERNAL "")
@@ -90,6 +95,15 @@ include_directories(src)
find_package(weaseljson REQUIRED)
# Suppress deprecated literal operator warnings globally (from nlohmann_json and
# toml11)
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wno-unknown-warning-option
-Wno-deprecated-literal-operator)
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# GCC doesn't have deprecated-literal-operator warning, so no need to suppress
endif()
# Generate JSON token hash table using gperf
find_program(GPERF_EXECUTABLE gperf REQUIRED)
add_custom_command(
@@ -103,27 +117,8 @@ add_custom_command(
add_custom_target(generate_json_tokens
DEPENDS ${CMAKE_BINARY_DIR}/json_tokens.cpp)
set(SOURCES
src/main.cpp
src/config.cpp
src/connection.cpp
src/connection_registry.cpp
src/server.cpp
src/json_commit_request_parser.cpp
src/http_handler.cpp
src/arena_allocator.cpp
${CMAKE_BINARY_DIR}/json_tokens.cpp)
add_executable(weaseldb ${SOURCES})
add_dependencies(weaseldb generate_json_tokens)
target_link_libraries(
weaseldb
Threads::Threads
toml11::toml11
weaseljson
simdutf::simdutf
llhttp_static
perfetto)
add_executable(weaseldb src/main.cpp)
target_link_libraries(weaseldb weaseldb_sources)
enable_testing()
@@ -132,108 +127,156 @@ add_library(test_data STATIC benchmarks/test_data.cpp)
target_include_directories(test_data PUBLIC benchmarks)
target_link_libraries(test_data simdutf::simdutf)
add_executable(test_arena_allocator tests/test_arena_allocator.cpp
src/arena_allocator.cpp)
target_link_libraries(test_arena_allocator doctest::doctest)
target_include_directories(test_arena_allocator PRIVATE src)
target_compile_options(test_arena_allocator PRIVATE -UNDEBUG)
# Create doctest implementation library
add_library(doctest_impl STATIC doctest_impl.cpp)
target_link_libraries(doctest_impl PUBLIC doctest::doctest)
add_executable(
test_commit_request
tests/test_commit_request.cpp src/json_commit_request_parser.cpp
tests/nlohmann_reference_parser.cpp tests/parser_comparison.cpp
src/arena_allocator.cpp ${CMAKE_BINARY_DIR}/json_tokens.cpp)
add_dependencies(test_commit_request generate_json_tokens)
target_link_libraries(test_commit_request doctest::doctest weaseljson test_data
nlohmann_json::nlohmann_json simdutf::simdutf)
target_include_directories(test_commit_request PRIVATE src tests)
target_compile_options(test_commit_request PRIVATE -UNDEBUG)
# Create nanobench implementation library
add_library(nanobench_impl STATIC nanobench_impl.cpp)
target_link_libraries(nanobench_impl PUBLIC nanobench)
add_executable(
test_http_handler
tests/test_http_handler.cpp src/http_handler.cpp src/arena_allocator.cpp
src/connection.cpp src/connection_registry.cpp)
target_link_libraries(test_http_handler doctest::doctest llhttp_static
Threads::Threads perfetto)
target_include_directories(test_http_handler PRIVATE src)
target_compile_definitions(test_http_handler
PRIVATE DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN)
target_compile_options(test_http_handler PRIVATE -UNDEBUG)
add_executable(
test_server_connection_return
tests/test_server_connection_return.cpp
# Define all source files in one place
set(WEASELDB_SOURCES
src/arena.cpp
src/commit_pipeline.cpp
src/cpu_work.cpp
src/format.cpp
src/metric.cpp
src/json_commit_request_parser.cpp
src/api_url_parser.cpp
src/server.cpp
src/connection.cpp
src/connection_registry.cpp
src/arena_allocator.cpp
src/config.cpp
src/http_handler.cpp
src/config.cpp
src/process_collector.cpp
${CMAKE_BINARY_DIR}/json_tokens.cpp)
add_dependencies(test_server_connection_return generate_json_tokens)
target_link_libraries(
test_server_connection_return
doctest::doctest
llhttp_static
Threads::Threads
toml11::toml11
perfetto
weaseljson
simdutf::simdutf)
target_include_directories(test_server_connection_return PRIVATE src)
target_compile_definitions(test_server_connection_return
PRIVATE DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN)
target_compile_options(test_server_connection_return PRIVATE -UNDEBUG)
add_executable(bench_arena_allocator benchmarks/bench_arena_allocator.cpp
src/arena_allocator.cpp)
target_link_libraries(bench_arena_allocator nanobench)
target_include_directories(bench_arena_allocator PRIVATE src)
# Create library based on build type
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
# In debug builds, use single library with assertions enabled
add_library(weaseldb_sources STATIC ${WEASELDB_SOURCES})
add_dependencies(weaseldb_sources generate_json_tokens)
target_include_directories(weaseldb_sources PUBLIC src)
target_link_libraries(
weaseldb_sources PUBLIC simdutf::simdutf weaseljson Threads::Threads
llhttp_static toml11::toml11 perfetto)
target_compile_options(weaseldb_sources PRIVATE -UNDEBUG)
add_executable(bench_volatile_loop benchmarks/bench_volatile_loop.cpp)
target_link_libraries(bench_volatile_loop nanobench)
# Alias for tests to use same target name
add_library(weaseldb_sources_debug ALIAS weaseldb_sources)
else()
# In release builds, create both variants
add_library(weaseldb_sources STATIC ${WEASELDB_SOURCES})
add_dependencies(weaseldb_sources generate_json_tokens)
target_include_directories(weaseldb_sources PUBLIC src)
target_link_libraries(
weaseldb_sources PUBLIC simdutf::simdutf weaseljson Threads::Threads
llhttp_static toml11::toml11 perfetto)
# Debug version with assertions enabled for tests
add_library(weaseldb_sources_debug STATIC ${WEASELDB_SOURCES})
add_dependencies(weaseldb_sources_debug generate_json_tokens)
target_include_directories(weaseldb_sources_debug PUBLIC src)
target_link_libraries(
weaseldb_sources_debug PUBLIC simdutf::simdutf weaseljson Threads::Threads
llhttp_static toml11::toml11 perfetto)
target_compile_options(weaseldb_sources_debug PRIVATE -UNDEBUG)
endif()
add_executable(test_arena tests/test_arena.cpp)
target_link_libraries(test_arena doctest_impl weaseldb_sources_debug)
target_compile_options(test_arena PRIVATE -UNDEBUG)
add_executable(test_server tests/test_server.cpp)
target_link_libraries(test_server doctest_impl weaseldb_sources_debug)
target_compile_options(test_server PRIVATE -UNDEBUG)
add_test(NAME test_server COMMAND test_server)
add_executable(
bench_commit_request
benchmarks/bench_commit_request.cpp src/json_commit_request_parser.cpp
src/arena_allocator.cpp ${CMAKE_BINARY_DIR}/json_tokens.cpp)
add_dependencies(bench_commit_request generate_json_tokens)
target_link_libraries(bench_commit_request nanobench weaseljson test_data
simdutf::simdutf)
target_include_directories(bench_commit_request PRIVATE src)
test_commit_request
tests/test_commit_request.cpp tests/nlohmann_reference_parser.cpp
tests/parser_comparison.cpp)
target_link_libraries(test_commit_request doctest_impl weaseldb_sources_debug
test_data nlohmann_json::nlohmann_json)
target_include_directories(test_commit_request PRIVATE tests)
target_compile_options(test_commit_request PRIVATE -UNDEBUG)
add_executable(
bench_parser_comparison
benchmarks/bench_parser_comparison.cpp src/json_commit_request_parser.cpp
src/arena_allocator.cpp ${CMAKE_BINARY_DIR}/json_tokens.cpp)
add_dependencies(bench_parser_comparison generate_json_tokens)
target_link_libraries(bench_parser_comparison nanobench weaseljson test_data
nlohmann_json::nlohmann_json simdutf::simdutf)
# Metrics system test
add_executable(test_metric tests/test_metric.cpp)
target_link_libraries(test_metric doctest_impl weaseldb_sources_debug)
target_compile_options(test_metric PRIVATE -UNDEBUG)
# HTTP handler test
add_executable(test_http_handler tests/test_http_handler.cpp)
target_link_libraries(test_http_handler doctest_impl weaseldb_sources_debug)
target_compile_options(test_http_handler PRIVATE -UNDEBUG)
add_test(NAME test_http_handler COMMAND test_http_handler)
# Register with CTest
add_test(NAME test_metric COMMAND test_metric)
add_executable(bench_arena benchmarks/bench_arena.cpp)
target_link_libraries(bench_arena nanobench_impl weaseldb_sources)
add_executable(bench_cpu_work benchmarks/bench_cpu_work.cpp src/cpu_work.cpp)
target_link_libraries(bench_cpu_work nanobench_impl)
add_executable(bench_commit_request benchmarks/bench_commit_request.cpp)
target_link_libraries(bench_commit_request nanobench_impl weaseldb_sources
test_data)
add_executable(bench_parser_comparison benchmarks/bench_parser_comparison.cpp)
target_link_libraries(bench_parser_comparison nanobench_impl weaseldb_sources
test_data nlohmann_json::nlohmann_json)
target_include_directories(bench_parser_comparison
PRIVATE src ${rapidjson_SOURCE_DIR}/include)
PRIVATE ${rapidjson_SOURCE_DIR}/include)
add_executable(bench_thread_pipeline benchmarks/bench_thread_pipeline.cpp)
target_link_libraries(bench_thread_pipeline nanobench Threads::Threads)
add_executable(bench_thread_pipeline benchmarks/bench_thread_pipeline.cpp
src/cpu_work.cpp)
target_link_libraries(bench_thread_pipeline nanobench_impl Threads::Threads)
target_include_directories(bench_thread_pipeline PRIVATE src)
add_executable(bench_format_comparison benchmarks/bench_format_comparison.cpp)
target_link_libraries(bench_format_comparison nanobench_impl weaseldb_sources)
# Metrics system benchmark
add_executable(bench_metric benchmarks/bench_metric.cpp)
target_link_libraries(bench_metric nanobench_impl weaseldb_sources)
# Register benchmark with CTest
add_test(NAME metric_benchmarks COMMAND bench_metric)
# Debug tools
add_executable(
debug_arena tools/debug_arena.cpp src/json_commit_request_parser.cpp
src/arena_allocator.cpp ${CMAKE_BINARY_DIR}/json_tokens.cpp)
add_dependencies(debug_arena generate_json_tokens)
target_link_libraries(debug_arena weaseljson simdutf::simdutf)
target_include_directories(debug_arena PRIVATE src)
add_executable(debug_arena tools/debug_arena.cpp)
target_link_libraries(debug_arena weaseldb_sources)
# Load tester
add_executable(load_tester tools/load_tester.cpp)
target_link_libraries(load_tester Threads::Threads llhttp_static perfetto)
add_test(NAME arena_allocator_tests COMMAND test_arena_allocator)
add_test(NAME commit_request_tests COMMAND test_commit_request)
add_test(NAME http_handler_tests COMMAND test_http_handler)
add_test(NAME server_connection_return_tests
COMMAND test_server_connection_return)
add_test(NAME arena_allocator_benchmarks COMMAND bench_arena_allocator)
add_test(NAME test_arena COMMAND test_arena)
add_test(NAME test_commit_request COMMAND test_commit_request)
add_test(NAME arena_benchmarks COMMAND bench_arena)
add_test(NAME commit_request_benchmarks COMMAND bench_commit_request)
add_test(NAME parser_comparison_benchmarks COMMAND bench_parser_comparison)
add_test(NAME thread_pipeline_benchmarks COMMAND bench_thread_pipeline)
add_test(NAME format_comparison_benchmarks COMMAND bench_format_comparison)
add_executable(test_api_url_parser tests/test_api_url_parser.cpp)
target_link_libraries(test_api_url_parser doctest_impl weaseldb_sources_debug)
target_compile_options(test_api_url_parser PRIVATE -UNDEBUG)
add_test(NAME test_api_url_parser COMMAND test_api_url_parser)
# Reference counting tests and benchmarks
add_executable(test_reference tests/test_reference.cpp)
target_link_libraries(test_reference doctest_impl)
target_include_directories(test_reference PRIVATE src)
target_compile_options(test_reference PRIVATE -UNDEBUG)
add_test(NAME test_reference COMMAND test_reference)
add_executable(bench_reference benchmarks/bench_reference.cpp)
target_link_libraries(bench_reference doctest_impl nanobench_impl
Threads::Threads)
target_include_directories(bench_reference PRIVATE src)
add_test(NAME reference_benchmarks COMMAND bench_reference)
+37 -23
View File
@@ -2,7 +2,7 @@
> **Note:** This is a design for the API of the write-side of a database system where writing and reading are decoupled. The read-side of the system is expected to use the `/v1/subscribe` endpoint to maintain a queryable representation of the key-value data. In other words, reading from this "database" is left as an exercise for the reader. Authentication and authorization are out of scope for this design.
-----
______________________________________________________________________
## `GET /v1/version`
@@ -20,16 +20,16 @@ Retrieves the latest known committed version and the current leader.
}
```
-----
______________________________________________________________________
## `POST /v1/commit`
Submits a transaction to be committed. The transaction consists of read preconditions, writes, and deletes.
* Clients may receive a **`413 Content Too Large`** response if the request exceeds a configurable limit.
* A malformed request will result in a **`400 Bad Request`** response.
* Keys are sorted by a lexicographical comparison of their raw byte values.
* All binary data for keys and values must be encoded using the standard base64 scheme defined in [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4), with padding included.
- Clients may receive a **`413 Content Too Large`** response if the request exceeds a configurable limit.
- A malformed request will result in a **`400 Bad Request`** response.
- Keys are sorted by a lexicographical comparison of their raw byte values.
- All binary data for keys and values must be encoded using the standard base64 scheme defined in [RFC 4648](https://datatracker.ietf.org/doc/html/rfc4648#section-4), with padding included.
### Request
@@ -91,7 +91,9 @@ Submits a transaction to be committed. The transaction consists of read precondi
// If not committed, a more recent version that the client can use to retry.
"version": 123456,
// The unique ID of the leader at this version.
"leader_id": "abcdefg"
"leader_id": "abcdefg",
// Echo back the request_id if it was provided in the original request
"request_id": "abcdefg"
}
```
@@ -99,16 +101,16 @@ Submits a transaction to be committed. The transaction consists of read precondi
1. **`request_id`**: Optional field that can be used with `/v1/status` to determine the outcome if no reply is received. If omitted, a UUID will be automatically generated by the server, and clients will not be able to determine commit status if there's no response. When provided, the request_id must meet the minimum length requirement (configurable, default 20 characters) to ensure sufficient entropy for collision avoidance. This ID must not be reused in a commit request. For idempotency, if a response is not received, the client must use `/v1/status` to determine the request's outcome. The original `request_id` should not be reused for a new commit attempt; instead, a retry should be sent with a new `request_id`. The alternative design would require the leader to store every request ID in memory.
2. **`preconditions` (Guarantees and Usage)**: The condition is satisfied if the server verifies that the range has not changed since the specified version. Clients can achieve serializable isolation by including all reads that influenced their writes. By default, clients should assume that any read they perform influences their writes. Omitting reads is an expert-level optimization and should generally be avoided.
1. **`preconditions` (Guarantees and Usage)**: The condition is satisfied if the server verifies that the range has not changed since the specified version. Clients can achieve serializable isolation by including all reads that influenced their writes. By default, clients should assume that any read they perform influences their writes. Omitting reads is an expert-level optimization and should generally be avoided.
3. **`preconditions` (False Positives & Leader Changes)**: Precondition checks are conservative and best-effort; it's possible to reject a transaction where the range hasn't actually changed. In all such cases, clients should retry with a more recent read version. Two examples of false positives are:
1. **`preconditions` (False Positives & Leader Changes)**: Precondition checks are conservative and best-effort; it's possible to reject a transaction where the range hasn't actually changed. In all such cases, clients should retry with a more recent read version. Two examples of false positives are:
* **Implementation Detail:** The leader may use partitioned conflict history for performance. A conflict in one partition (even from a transaction that later aborts) can cause a rejection.
* **Leader Changes:** A version is only valid within the term of the leader that issued it. Since conflict history is stored in memory, a leadership change invalidates all previously issued read versions. Any transaction using such a version will be rejected.
- **Implementation Detail:** The leader may use partitioned conflict history for performance. A conflict in one partition (even from a transaction that later aborts) can cause a rejection.
- **Leader Changes:** A version is only valid within the term of the leader that issued it. Since conflict history is stored in memory, a leadership change invalidates all previously issued read versions. Any transaction using such a version will be rejected.
The versions in the precondition checks need not be the same.
-----
______________________________________________________________________
## `GET /v1/status`
@@ -125,7 +127,7 @@ Gets the status of a previous commit request by its `request_id`.
| `request_id` | string | Yes | The `request_id` from the original `/v1/commit` request. |
| `min_version` | integer | Yes | An optimization that constrains the log scan. This value should be the latest version the client knew to be committed *before* sending the original request. |
> **Warning\!** If the provided `min_version` is later than the transaction's actual commit version, the server might not find the record in the scanned portion of the log. This can result in an `id_not_found` status, even if the transaction actually committed.
> **Warning!** If the provided `min_version` is later than the transaction's actual commit version, the server might not find the record in the scanned portion of the log. This can result in an `id_not_found` status, even if the transaction actually committed.
### Response
@@ -144,7 +146,7 @@ A response from this endpoint guarantees the original request is no longer in fl
> **Note on `log_truncated` status:** This indicates the `request_id` log has been truncated after `min_version`, making it impossible to determine the original request's outcome. There is no way to avoid this without storing an arbitrarily large number of request IDs. Clients must treat this as an indeterminate outcome. Retrying the transaction is unsafe unless the client has an external method to verify the original transaction's status. This error should be propagated to the caller. `request_id`s are retained for a configurable minimum time and number of versions so this should be extremely rare.
-----
______________________________________________________________________
## `GET /v1/subscribe`
@@ -169,7 +171,7 @@ The response is a stream of events compliant with the SSE protocol.
```
event: transaction
data: {"request_id":"abcdefg","version":123456,"timestamp":"2025-08-07T20:27:42.555Z","leader_id":"abcdefg","operations":[...]}
data: {"request_id":"abcdefg","version":123456,"prev_version":123455,"timestamp":"2025-08-07T20:27:42.555Z","leader_id":"abcdefg","operations":[...]}
```
@@ -192,9 +194,11 @@ data: {"committed_version":123456,"leader_id":"abcdefg"}
1. **Data Guarantees**: When `durable=false`, this endpoint streams *accepted*, but not necessarily *durable/committed*, transactions. *Accepted* transactions will eventually commit unless the current leader changes.
2. **Leader Changes & Reconnection**: When `durable=false`, if the leader changes, clients **must** discard all of that leader's `transaction` events received after their last-seen `checkpoint` event. They must then manually reconnect (as the server connection will likely be terminated) and restart the subscription by setting the `after` query parameter to the version specified in that last-known checkpoint. Clients should implement a randomized exponential backoff strategy (backoff with jitter) when reconnecting.
1. **Leader Changes & Reconnection**: When `durable=false`, if the leader changes, clients **must** discard all of that leader's `transaction` events received after their last-seen `checkpoint` event. They must then manually reconnect (as the server connection will likely be terminated) and restart the subscription by setting the `after` query parameter to the version specified in that last-known checkpoint. Clients should implement a randomized exponential backoff strategy (backoff with jitter) when reconnecting.
3. **Connection Handling & Errors**: The server may periodically send `keepalive` comments to prevent idle timeouts on network proxies. The server will buffer unconsumed data up to a configurable limit; if the client falls too far behind, the connection will be closed. If the `after` version has been truncated from the log, this endpoint will return a standard `410 Gone` HTTP error instead of an event stream.
1. **Gap Detection**: Each `transaction` event includes a `prev_version` field linking to the previous transaction version, forming a linked list. Clients can detect gaps in the transaction stream by checking that each transaction's `prev_version` matches the previous transaction's `version`. This ensures gapless transitions between historical data from S3 and live events from the server.
1. **Connection Handling & Errors**: The server may periodically send `keepalive` comments to prevent idle timeouts on network proxies. The server will buffer unconsumed data up to a configurable limit; if the client falls too far behind, the connection will be closed. If the `after` version has been truncated from the log, this endpoint will return a standard `410 Gone` HTTP error instead of an event stream.
## `PUT /v1/retention/<policy_id>`
@@ -211,10 +215,10 @@ Creates or updates a retention policy.
### Response
* `201 Created` if the policy was created.
* `200 OK` if the policy was updated.
- `201 Created` if the policy was created.
- `200 OK` if the policy was updated.
-----
______________________________________________________________________
## `GET /v1/retention/<policy_id>`
@@ -228,7 +232,7 @@ Retrieves a retention policy by ID.
}
```
-----
______________________________________________________________________
## `GET /v1/retention/`
@@ -245,7 +249,7 @@ Retrieves all retention policies.
]
```
-----
______________________________________________________________________
## `DELETE /v1/retention/<policy_id>`
@@ -255,7 +259,17 @@ Removes a retention policy, which may allow the log to be truncated.
`204 No Content`
-----
______________________________________________________________________
## `GET /ok`
Simple health check endpoint.
### Response
Returns `200 OK` with minimal content for basic health monitoring.
______________________________________________________________________
## `GET /metrics`
@@ -1,4 +1,4 @@
#include "arena_allocator.hpp"
#include "arena.hpp"
#include <nanobench.h>
#include <vector>
@@ -14,8 +14,8 @@ int main() {
{
// Arena allocator benchmark
ArenaAllocator arena;
bench.run("ArenaAllocator", [&] {
Arena arena;
bench.run("Arena", [&] {
void *ptr = arena.allocate_raw(alloc_size);
ankerl::nanobench::doNotOptimizeAway(ptr);
});
+34
View File
@@ -0,0 +1,34 @@
#include <iostream>
#include <nanobench.h>
#include <string>
#include "../src/cpu_work.hpp"
int main(int argc, char *argv[]) {
int iterations = DEFAULT_HEALTH_CHECK_ITERATIONS;
if (argc > 1) {
try {
iterations = std::stoi(argv[1]);
if (iterations < 0) {
std::cerr << "Error: iterations must be non-negative" << std::endl;
return 1;
}
} catch (const std::exception &e) {
std::cerr << "Error: invalid number '" << argv[1] << "'" << std::endl;
return 1;
}
}
std::cout << "Benchmarking spend_cpu_cycles with " << iterations
<< " iterations" << std::endl;
ankerl::nanobench::Bench bench;
bench.minEpochIterations(10000);
// Benchmark the same CPU work that health checks use
bench.run("spend_cpu_cycles(" + std::to_string(iterations) + ")",
[&] { spend_cpu_cycles(iterations); });
return 0;
}
+281
View File
@@ -0,0 +1,281 @@
#include "arena.hpp"
#include "format.hpp"
#include <cstdio>
#include <iomanip>
#include <nanobench.h>
#include <sstream>
#include <string>
#if __cpp_lib_format >= 201907L
#include <format>
#define HAS_STD_FORMAT 1
#else
#define HAS_STD_FORMAT 0
#endif
// Test data for consistent benchmarks
constexpr int TEST_INT = 42;
constexpr double TEST_DOUBLE =
3.141592653589793; // Exact IEEE 754 representation of π
const std::string TEST_STRING = "Hello World";
// Benchmark simple string concatenation: "Hello " + "World" + "!"
void benchmark_simple_concatenation() {
std::cout << "\n=== Simple String Concatenation: 'Hello World!' ===\n";
ankerl::nanobench::Bench bench;
bench.title("Simple Concatenation").unit("op").warmup(100);
Arena arena(64);
// Arena-based static_format
bench.run("static_format", [&] {
auto result = static_format(arena, "Hello ", "World", "!");
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// Arena-based format
bench.run("format", [&] {
auto result = format(arena, "Hello %s!", "World");
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// std::stringstream
bench.run("std::stringstream", [&] {
std::stringstream ss;
ss << "Hello " << "World" << "!";
auto result = ss.str();
ankerl::nanobench::doNotOptimizeAway(result);
});
#if HAS_STD_FORMAT
// std::format (C++20)
bench.run("std::format", [&] {
auto result = std::format("Hello {}!", "World");
ankerl::nanobench::doNotOptimizeAway(result);
});
#endif
}
// Benchmark mixed type formatting: "Count: 42, Rate: 3.14159"
void benchmark_mixed_types() {
std::cout << "\n=== Mixed Type Formatting: 'Count: 42, Rate: 3.14159' ===\n";
ankerl::nanobench::Bench bench;
bench.title("Mixed Types").unit("op").warmup(100);
Arena arena(128);
// Arena-based static_format
bench.run("static_format", [&] {
auto result =
static_format(arena, "Count: ", TEST_INT, ", Rate: ", TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// Arena-based format
bench.run("format", [&] {
auto result = format(arena, "Count: %d, Rate: %.5f", TEST_INT, TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// std::stringstream
bench.run("std::stringstream", [&] {
std::stringstream ss;
ss << "Count: " << TEST_INT << ", Rate: " << std::fixed
<< std::setprecision(5) << TEST_DOUBLE;
auto result = ss.str();
ankerl::nanobench::doNotOptimizeAway(result);
});
#if HAS_STD_FORMAT
// std::format (C++20)
bench.run("std::format", [&] {
auto result = std::format("Count: {}, Rate: {:.5f}", TEST_INT, TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
});
#endif
}
// Benchmark complex formatting with precision and alignment
void benchmark_complex_formatting() {
std::cout << "\n=== Complex Formatting: '%-10s %5d %8.2f' ===\n";
ankerl::nanobench::Bench bench;
bench.title("Complex Formatting").unit("op").warmup(100);
Arena arena(128);
// Arena-based format (static_format doesn't support printf specifiers)
bench.run("format", [&] {
auto result = format(arena, "%-10s %5d %8.2f", TEST_STRING.c_str(),
TEST_INT, TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// std::stringstream
bench.run("std::stringstream", [&] {
std::stringstream ss;
ss << std::left << std::setw(10) << TEST_STRING << " " << std::right
<< std::setw(5) << TEST_INT << " " << std::setw(8) << std::fixed
<< std::setprecision(2) << TEST_DOUBLE;
auto result = ss.str();
ankerl::nanobench::doNotOptimizeAway(result);
});
#if HAS_STD_FORMAT
// std::format (C++20)
bench.run("std::format", [&] {
auto result = std::format("{:<10} {:>5} {:>8.2f}", TEST_STRING, TEST_INT,
TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
});
#endif
}
// Benchmark error message formatting (common use case)
void benchmark_error_messages() {
std::cout << "\n=== Error Message Formatting: 'Error 404: File not found "
"(line 123)' ===\n";
ankerl::nanobench::Bench bench;
bench.title("Error Messages").unit("op").warmup(100);
constexpr int error_code = 404;
constexpr int line_number = 123;
const std::string error_msg = "File not found";
Arena arena(128);
// Arena-based static_format (using string literals only)
bench.run("static_format", [&] {
auto result = static_format(arena, "Error ", error_code, ": ",
"File not found", " (line ", line_number, ")");
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// Arena-based format
bench.run("format", [&] {
auto result = format(arena, "Error %d: %s (line %d)", error_code,
error_msg.c_str(), line_number);
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// std::stringstream
bench.run("std::stringstream", [&] {
std::stringstream ss;
ss << "Error " << error_code << ": " << error_msg << " (line "
<< line_number << ")";
auto result = ss.str();
ankerl::nanobench::doNotOptimizeAway(result);
});
#if HAS_STD_FORMAT
// std::format (C++20)
bench.run("std::format", [&] {
auto result = std::format("Error {}: {} (line {})", error_code, error_msg,
line_number);
ankerl::nanobench::doNotOptimizeAway(result);
});
#endif
}
// Benchmark simple double formatting (common in metrics)
void benchmark_double_formatting() {
std::cout << "\n=== Simple Double Formatting ===\n";
// Validate that all formatters produce identical output
Arena arena(128);
auto static_result = static_format(arena, TEST_DOUBLE);
auto format_result = format(arena, "%.17g", TEST_DOUBLE);
std::stringstream ss;
ss << std::setprecision(17) << TEST_DOUBLE;
auto stringstream_result = ss.str();
#if HAS_STD_FORMAT
auto std_format_result = std::format("{}", TEST_DOUBLE);
#endif
std::cout << "Validation (note: precision algorithms may differ):\n";
std::cout << " static_format: '" << static_result
<< "' (length: " << static_result.length() << ")\n";
std::cout << " format(%.17g): '" << format_result
<< "' (length: " << format_result.length() << ")\n";
std::cout << " std::stringstream: '" << stringstream_result
<< "' (length: " << stringstream_result.length() << ")\n";
#if HAS_STD_FORMAT
std::cout << " std::format: '" << std_format_result
<< "' (length: " << std_format_result.length() << ")\n";
#endif
std::cout
<< "Note: Different formatters may use different precision algorithms\n";
std::cout << "Proceeding with performance comparison...\n";
ankerl::nanobench::Bench bench;
bench.title("Double Formatting").unit("op").warmup(100);
// Arena-based static_format (double only)
bench.run("static_format(double)", [&] {
auto result = static_format(arena, TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// Arena-based format with equivalent precision
bench.run("format(%.17g)", [&] {
// Use %.17g to match static_format's full precision behavior
auto result = format(arena, "%.17g", TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
arena.reset();
});
// std::stringstream (full precision)
bench.run("std::stringstream", [&] {
std::stringstream ss;
ss << std::setprecision(17) << TEST_DOUBLE;
auto result = ss.str();
ankerl::nanobench::doNotOptimizeAway(result);
});
#if HAS_STD_FORMAT
// std::format (C++20) - default formatting
bench.run("std::format", [&] {
auto result = std::format("{}", TEST_DOUBLE);
ankerl::nanobench::doNotOptimizeAway(result);
});
#endif
}
int main() {
std::cout << "Format Function Benchmark Comparison\n";
std::cout << "====================================\n";
#if HAS_STD_FORMAT
std::cout << "C++20 std::format: Available\n";
#else
std::cout << "C++20 std::format: Not available\n";
#endif
benchmark_simple_concatenation();
benchmark_mixed_types();
benchmark_complex_formatting();
benchmark_error_messages();
benchmark_double_formatting();
std::cout << "\n=== Summary ===\n";
std::cout
<< "* static_format: Best for simple concatenation with known types\n";
std::cout
<< "* format: Best for printf-style formatting with arena allocation\n";
std::cout
<< "* std::stringstream: Flexible but slower due to heap allocation\n";
std::cout << "* std::format: Modern C++20 alternative (if available)\n";
return 0;
}
+217
View File
@@ -0,0 +1,217 @@
#include <nanobench.h>
#include "arena.hpp"
#include "metric.hpp"
#include <atomic>
#include <cmath>
#include <latch>
#include <random>
#include <thread>
#include <vector>
metric::Family<metric::Gauge> gauge_family = metric::create_gauge("gauge", "");
metric::Family<metric::Counter> counter_family =
metric::create_counter("counter", "");
metric::Family<metric::Histogram> histogram_family = metric::create_histogram(
"histogram", "", metric::exponential_buckets(0.001, 5, 8));
// High-contention benchmark setup
struct ContentionEnvironment {
// Background threads for contention
std::vector<std::thread> background_threads;
std::atomic<bool> stop_flag{false};
// Synchronization latches - must be members to avoid use-after-return
std::unique_ptr<std::latch> contention_latch;
std::unique_ptr<std::latch> render_latch;
ContentionEnvironment() = default;
void start_background_contention(int num_threads = 4) {
stop_flag.store(false);
contention_latch = std::make_unique<std::latch>(num_threads + 1);
for (int i = 0; i < num_threads; ++i) {
background_threads.emplace_back([this, i]() {
auto bg_counter = counter_family.create({});
auto bg_gauge = gauge_family.create({});
auto bg_histogram = histogram_family.create({});
std::mt19937 rng(i);
std::uniform_real_distribution<double> dist(0.0, 10.0);
contention_latch
->arrive_and_wait(); // All background threads start together
while (!stop_flag.load(std::memory_order_relaxed)) {
// Simulate mixed workload
bg_counter.inc(1.0);
bg_gauge.set(dist(rng));
bg_gauge.inc(1.0);
bg_histogram.observe(dist(rng));
}
});
}
contention_latch
->arrive_and_wait(); // Wait for all background threads to be ready
}
void start_render_thread() {
render_latch = std::make_unique<std::latch>(2);
background_threads.emplace_back([this]() {
Arena arena;
render_latch->arrive_and_wait(); // Render thread signals it's ready
while (!stop_flag.load(std::memory_order_relaxed)) {
auto output = metric::render(arena);
static_cast<void>(output); // Suppress unused variable warning
arena.reset();
}
});
render_latch->arrive_and_wait(); // Wait for render thread to be ready
}
void stop_background_threads() {
stop_flag.store(true);
for (auto &t : background_threads) {
if (t.joinable()) {
t.join();
}
}
background_threads.clear();
}
~ContentionEnvironment() { stop_background_threads(); }
};
int main() {
ankerl::nanobench::Bench bench;
bench.title("WeaselDB Metrics Performance").unit("operation").warmup(1000);
auto counter = counter_family.create({});
auto gauge = gauge_family.create({});
auto histogram = histogram_family.create({});
// Baseline performance without contention
{
bench.run("counter.inc() - no contention", [&]() {
counter.inc(1.0);
ankerl::nanobench::doNotOptimizeAway(counter);
});
bench.run("gauge.inc() - no contention", [&]() {
gauge.inc(1.0);
ankerl::nanobench::doNotOptimizeAway(gauge);
});
bench.run("gauge.set() - no contention", [&]() {
gauge.set(42.0);
ankerl::nanobench::doNotOptimizeAway(gauge);
});
bench.run("histogram.observe() - no contention", [&]() {
histogram.observe(0.5);
ankerl::nanobench::doNotOptimizeAway(histogram);
});
}
// High contention with background threads
{
ContentionEnvironment env;
// Start background threads creating contention
env.start_background_contention(8);
bench.run("counter.inc() - 8 background threads",
[&]() { counter.inc(1.0); });
bench.run("gauge.inc() - 8 background threads", [&]() { gauge.inc(1.0); });
bench.run("gauge.set() - 8 background threads", [&]() { gauge.set(42.0); });
bench.run("histogram.observe() - 8 background threads",
[&]() { histogram.observe(1.5); });
}
// Concurrent render contention
{
ContentionEnvironment env;
// Start background threads + render thread
env.start_background_contention(4);
env.start_render_thread();
bench.run("counter.inc() - with concurrent render",
[&]() { counter.inc(1.0); });
bench.run("gauge.inc() - with concurrent render",
[&]() { gauge.inc(1.0); });
bench.run("histogram.observe() - with concurrent render",
[&]() { histogram.observe(2.0); });
}
// Render performance scaling
{
bench.unit("metric");
bench.title("render performance");
// Test render performance as number of metrics increases
// Create varying numbers of metrics
for (int scale : {10, 100, 1000}) {
metric::reset_metrics_for_testing();
std::vector<metric::Counter> counters;
std::vector<metric::Gauge> gauges;
std::vector<metric::Histogram> histograms;
auto counter_family =
metric::create_counter("scale_counter", "Scale counter");
auto gauge_family = metric::create_gauge("scale_gauge", "Scale gauge");
auto buckets = std::initializer_list<double>{0.1, 0.5, 1.0, 2.5,
5.0, 10.0, 25.0, 50.0};
auto histogram_family = metric::create_histogram(
"scale_histogram", "Scale histogram", buckets);
std::atomic<double> counter_value{3.1415924654};
bench.batch(scale * (/*counter*/ 1 + /*gauge*/ 1 + /*callback*/ 1 +
/*histogram*/ (buckets.size() * 2 + 2)));
// Clear previous metrics by creating new families
// (Note: In real usage, metrics persist for application lifetime)
for (int i = 0; i < scale; ++i) {
counters.emplace_back(
counter_family.create({{"id", std::to_string(i)}}));
gauges.emplace_back(gauge_family.create({{"id", std::to_string(i)}}));
histograms.emplace_back(
histogram_family.create({{"id", std::to_string(i)}}));
// Set some values
counters.back().inc(static_cast<double>(i));
gauges.back().set(static_cast<double>(i * 2));
histograms.back().observe(static_cast<double>(i) * 0.1);
// Register callbacks
counter_family.register_callback(
{{"type", "callback"}, {"id", std::to_string(i)}},
[&counter_value]() {
return counter_value.load(std::memory_order_relaxed);
});
}
Arena arena;
std::string bench_name =
"render() - " + std::to_string(scale) + " metrics each type";
bench.run(bench_name, [&]() {
auto output = metric::render(arena);
ankerl::nanobench::doNotOptimizeAway(output);
arena.reset();
});
}
}
return 0;
}
+6 -13
View File
@@ -14,9 +14,9 @@
using namespace weaseldb::test_data;
// Arena-based allocator adapter for RapidJSON
class RapidJsonArenaAllocator {
class RapidJsonArenaAdapter {
public:
explicit RapidJsonArenaAllocator(ArenaAllocator *arena) : arena_(arena) {}
explicit RapidJsonArenaAdapter(Arena *arena) : arena_(arena) {}
static const bool kNeedFree = false;
@@ -37,7 +37,7 @@ public:
}
private:
ArenaAllocator *arena_;
Arena *arena_;
};
// Arena-based RapidJSON SAX handler for commit request parsing
@@ -56,7 +56,7 @@ public:
std::string_view key, value, begin, end;
};
ArenaAllocator arena;
Arena arena;
bool valid = true;
std::string_view request_id, leader_id;
uint64_t read_version = 0;
@@ -76,13 +76,6 @@ private:
Precondition current_precondition;
Operation current_operation;
// Helper to store string in arena and return string_view
std::string_view store_string(const char *str, size_t length) {
char *stored = arena.allocate<char>(length);
std::memcpy(stored, str, length);
return std::string_view(stored, length);
}
public:
explicit CommitRequestArenaHandler()
: preconditions(ArenaStlAllocator<Precondition>(&arena)),
@@ -109,7 +102,7 @@ public:
bool RawNumber(const char *, rapidjson::SizeType, bool) { abort(); }
bool String(const char *str, rapidjson::SizeType length, bool) {
std::string_view value = store_string(str, length);
std::string_view value = arena.copy_string({str, length});
if (state == State::Root) {
if (current_key == "request_id") {
@@ -789,7 +782,7 @@ int main() {
std::cout << "\nBenchmark completed. The WeaselDB parser is optimized for:\n";
std::cout << "- Arena-based memory allocation for reduced fragmentation\n";
std::cout << "- Streaming parsing for network protocols\n";
std::cout << "- Zero-copy string handling with string views\n";
std::cout << "- String views to minimize unnecessary copying\n";
std::cout << "- Base64 decoding integrated into parsing pipeline\n";
std::cout << "- Efficient reset and reuse for high-throughput scenarios\n";
+424
View File
@@ -0,0 +1,424 @@
#include <memory>
#include <thread>
#include <vector>
#include <doctest/doctest.h>
#include <nanobench.h>
#include "reference.hpp"
namespace {
struct TestObject {
int64_t data = 42;
TestObject() = default;
explicit TestObject(int64_t value) : data(value) {}
};
// Trait helpers for templated benchmarks
template <typename T> struct PointerTraits;
template <typename T> struct PointerTraits<std::shared_ptr<T>> {
using pointer_type = std::shared_ptr<T>;
using weak_type = std::weak_ptr<T>;
template <typename... Args> static pointer_type make(Args &&...args) {
return std::make_shared<T>(std::forward<Args>(args)...);
}
static pointer_type copy(const pointer_type &ptr) {
return ptr; // std::shared_ptr copies implicitly
}
static weak_type as_weak(const pointer_type &ptr) {
return ptr; // std::weak_ptr converts implicitly from std::shared_ptr
}
static weak_type copy_weak(const weak_type &weak) {
return weak; // std::weak_ptr copies implicitly
}
static const char *name() { return "std::shared_ptr"; }
static const char *weak_name() { return "std::weak_ptr"; }
};
template <typename T> struct PointerTraits<Ref<T>> {
using pointer_type = Ref<T>;
using weak_type = WeakRef<T>;
template <typename... Args> static pointer_type make(Args &&...args) {
return make_ref<T>(std::forward<Args>(args)...);
}
static pointer_type copy(const pointer_type &ptr) {
return ptr.copy(); // Ref requires explicit copy
}
static weak_type as_weak(const pointer_type &ptr) {
return ptr.as_weak(); // Ref requires explicit as_weak
}
static weak_type copy_weak(const weak_type &weak) {
return weak.copy(); // WeakRef requires explicit copy
}
static const char *name() { return "Ref"; }
static const char *weak_name() { return "WeakRef"; }
};
// Force multi-threaded mode to defeat __libc_single_threaded optimization
void force_multithreaded() {
std::thread t([]() {});
t.join();
}
template <typename PtrType>
void benchmark_creation(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
force_multithreaded();
bench.run(std::string(Traits::name()) + " creation", [&] {
auto ptr = Traits::make(TestObject{123});
ankerl::nanobench::doNotOptimizeAway(ptr);
});
}
template <typename PtrType>
void benchmark_copy(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
force_multithreaded();
auto original = Traits::make(TestObject{123});
bench.run(std::string(Traits::name()) + " copy", [&] {
auto copy = Traits::copy(original);
ankerl::nanobench::doNotOptimizeAway(copy);
});
}
template <typename PtrType>
void benchmark_move(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
auto original = Traits::make(TestObject{123});
bench.run(std::string(Traits::name()) + " move", [&] {
auto moved = std::move(original);
ankerl::nanobench::doNotOptimizeAway(moved);
original = std::move(moved);
ankerl::nanobench::doNotOptimizeAway(original);
});
}
template <typename PtrType>
void benchmark_weak_copy(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
force_multithreaded();
auto strong_ptr = Traits::make(TestObject{123});
typename Traits::weak_type weak_original = Traits::as_weak(strong_ptr);
bench.run(std::string(Traits::weak_name()) + " copy", [&] {
auto weak_copy = Traits::copy_weak(weak_original);
ankerl::nanobench::doNotOptimizeAway(weak_copy);
});
}
template <typename PtrType>
void benchmark_weak_move(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
auto strong_ptr = Traits::make(TestObject{123});
typename Traits::weak_type weak_original = Traits::as_weak(strong_ptr);
bench.run(std::string(Traits::weak_name()) + " move", [&] {
auto weak_moved = std::move(weak_original);
ankerl::nanobench::doNotOptimizeAway(weak_moved);
weak_original = std::move(weak_moved);
ankerl::nanobench::doNotOptimizeAway(weak_original);
});
}
template <typename PtrType>
void benchmark_dereference(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
auto ptr = Traits::make(TestObject{456});
bench.run(std::string(Traits::name()) + " dereference",
[&] { ankerl::nanobench::doNotOptimizeAway(ptr->data); });
}
template <typename PtrType>
void benchmark_weak_lock_success(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
auto strong_ptr = Traits::make(TestObject{789});
typename Traits::weak_type weak_ptr = Traits::as_weak(strong_ptr);
bench.run(std::string(Traits::weak_name()) + " lock success", [&] {
auto locked = weak_ptr.lock();
ankerl::nanobench::doNotOptimizeAway(locked);
});
}
template <typename PtrType>
void benchmark_weak_lock_failure(ankerl::nanobench::Bench &bench) {
using Traits = PointerTraits<PtrType>;
typename Traits::weak_type weak_ptr;
{
auto strong_ptr = Traits::make(TestObject{999});
weak_ptr = Traits::as_weak(strong_ptr);
}
bench.run(std::string(Traits::weak_name()) + " lock failure", [&] {
auto locked = weak_ptr.lock();
ankerl::nanobench::doNotOptimizeAway(locked);
});
}
template <typename PtrType>
void benchmark_multithreaded_copy(ankerl::nanobench::Bench &bench,
int num_threads) {
using Traits = PointerTraits<PtrType>;
// Create the shared object outside the benchmark
auto ptr = Traits::make(TestObject{456});
// Create background threads that will create contention
std::atomic<bool> keep_running{true};
std::vector<std::thread> background_threads;
for (int i = 0; i < num_threads - 1; ++i) {
background_threads.emplace_back([&]() {
while (keep_running.load(std::memory_order_relaxed)) {
auto copy = Traits::copy(ptr);
ankerl::nanobench::doNotOptimizeAway(copy);
}
});
}
// Benchmark the foreground thread under contention
bench.run(std::string(Traits::name()) + " copy under contention", [&] {
auto copy = Traits::copy(ptr);
ankerl::nanobench::doNotOptimizeAway(copy);
});
// Clean up background threads
keep_running.store(false, std::memory_order_relaxed);
for (auto &t : background_threads) {
t.join();
}
}
template <typename PtrType>
void benchmark_multithreaded_weak_lock(ankerl::nanobench::Bench &bench,
int num_threads) {
using Traits = PointerTraits<PtrType>;
// Create the shared object and weak reference outside the benchmark
auto strong_ptr = Traits::make(TestObject{789});
typename Traits::weak_type weak_ptr = Traits::as_weak(strong_ptr);
// Create background threads that will create contention
std::atomic<bool> keep_running{true};
std::vector<std::thread> background_threads;
for (int i = 0; i < num_threads - 1; ++i) {
background_threads.emplace_back([&]() {
while (keep_running.load(std::memory_order_relaxed)) {
auto locked = weak_ptr.lock();
ankerl::nanobench::doNotOptimizeAway(locked);
}
});
}
// Benchmark the foreground thread under contention
bench.run(std::string(Traits::weak_name()) + " lock under contention", [&] {
auto locked = weak_ptr.lock();
ankerl::nanobench::doNotOptimizeAway(locked);
});
// Clean up background threads
keep_running.store(false, std::memory_order_relaxed);
for (auto &t : background_threads) {
t.join();
}
}
template <typename PtrType>
void benchmark_weak_copy_with_strong_contention(ankerl::nanobench::Bench &bench,
int num_threads) {
using Traits = PointerTraits<PtrType>;
// Create the shared object and weak reference outside the benchmark
auto strong_ptr = Traits::make(TestObject{456});
typename Traits::weak_type weak_ptr = Traits::as_weak(strong_ptr);
// Create background threads copying the strong pointer
std::atomic<bool> keep_running{true};
std::vector<std::thread> background_threads;
for (int i = 0; i < num_threads - 1; ++i) {
background_threads.emplace_back([&]() {
while (keep_running.load(std::memory_order_relaxed)) {
auto copy = Traits::copy(strong_ptr);
ankerl::nanobench::doNotOptimizeAway(copy);
}
});
}
// Benchmark weak reference copying under strong reference contention
bench.run(std::string(Traits::weak_name()) + " copy with strong contention",
[&] {
auto weak_copy = Traits::copy_weak(weak_ptr);
ankerl::nanobench::doNotOptimizeAway(weak_copy);
});
// Clean up background threads
keep_running.store(false, std::memory_order_relaxed);
for (auto &t : background_threads) {
t.join();
}
}
template <typename PtrType>
void benchmark_strong_copy_with_weak_contention(ankerl::nanobench::Bench &bench,
int num_threads) {
using Traits = PointerTraits<PtrType>;
// Create the shared object and weak reference outside the benchmark
auto strong_ptr = Traits::make(TestObject{789});
typename Traits::weak_type weak_ptr = Traits::as_weak(strong_ptr);
// Create background threads copying the weak pointer
std::atomic<bool> keep_running{true};
std::vector<std::thread> background_threads;
for (int i = 0; i < num_threads - 1; ++i) {
background_threads.emplace_back([&]() {
while (keep_running.load(std::memory_order_relaxed)) {
auto weak_copy = Traits::copy_weak(weak_ptr);
ankerl::nanobench::doNotOptimizeAway(weak_copy);
}
});
}
// Benchmark strong reference copying under weak reference contention
bench.run(std::string(Traits::name()) + " copy with weak contention", [&] {
auto strong_copy = Traits::copy(strong_ptr);
ankerl::nanobench::doNotOptimizeAway(strong_copy);
});
// Clean up background threads
keep_running.store(false, std::memory_order_relaxed);
for (auto &t : background_threads) {
t.join();
}
}
} // anonymous namespace
TEST_CASE("Creation performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Creation performance comparison");
bench.relative(true);
benchmark_creation<std::shared_ptr<TestObject>>(bench);
benchmark_creation<Ref<TestObject>>(bench);
}
TEST_CASE("Copy performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Copy performance comparison");
bench.relative(true);
benchmark_copy<std::shared_ptr<TestObject>>(bench);
benchmark_copy<Ref<TestObject>>(bench);
}
TEST_CASE("Move performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Move performance comparison");
bench.relative(true);
benchmark_move<std::shared_ptr<TestObject>>(bench);
benchmark_move<Ref<TestObject>>(bench);
}
TEST_CASE("Weak copy performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Weak copy performance comparison");
bench.relative(true);
benchmark_weak_copy<std::shared_ptr<TestObject>>(bench);
benchmark_weak_copy<Ref<TestObject>>(bench);
}
TEST_CASE("Weak move performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Weak move performance comparison");
bench.relative(true);
benchmark_weak_move<std::shared_ptr<TestObject>>(bench);
benchmark_weak_move<Ref<TestObject>>(bench);
}
TEST_CASE("Dereference performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Dereference performance comparison");
bench.relative(true);
benchmark_dereference<std::shared_ptr<TestObject>>(bench);
benchmark_dereference<Ref<TestObject>>(bench);
}
TEST_CASE("Weak lock success performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Weak lock success performance comparison");
bench.relative(true);
benchmark_weak_lock_success<std::shared_ptr<TestObject>>(bench);
benchmark_weak_lock_success<Ref<TestObject>>(bench);
}
TEST_CASE("Weak lock failure performance comparison") {
ankerl::nanobench::Bench bench;
bench.title("Weak lock failure performance comparison");
bench.relative(true);
benchmark_weak_lock_failure<std::shared_ptr<TestObject>>(bench);
benchmark_weak_lock_failure<Ref<TestObject>>(bench);
}
TEST_CASE("Copy performance under contention") {
const int num_threads = 3;
ankerl::nanobench::Bench bench;
bench.title("Copy performance under contention");
bench.relative(true);
bench.minEpochIterations(500000);
benchmark_multithreaded_copy<std::shared_ptr<TestObject>>(bench, num_threads);
benchmark_multithreaded_copy<Ref<TestObject>>(bench, num_threads);
}
TEST_CASE("Weak lock performance under contention") {
const int num_threads = 3;
ankerl::nanobench::Bench bench;
bench.title("Weak lock performance under contention");
bench.relative(true);
bench.minEpochIterations(500000);
benchmark_multithreaded_weak_lock<std::shared_ptr<TestObject>>(bench,
num_threads);
benchmark_multithreaded_weak_lock<Ref<TestObject>>(bench, num_threads);
}
TEST_CASE("Weak copy performance under strong reference contention") {
const int num_threads = 3;
ankerl::nanobench::Bench bench;
bench.title("Weak copy performance under strong reference contention");
bench.relative(true);
bench.minEpochIterations(500000);
benchmark_weak_copy_with_strong_contention<std::shared_ptr<TestObject>>(
bench, num_threads);
benchmark_weak_copy_with_strong_contention<Ref<TestObject>>(bench,
num_threads);
}
TEST_CASE("Strong copy performance under weak reference contention") {
const int num_threads = 3;
ankerl::nanobench::Bench bench;
bench.title("Strong copy performance under weak reference contention");
bench.relative(true);
bench.minEpochIterations(500000);
benchmark_strong_copy_with_weak_contention<std::shared_ptr<TestObject>>(
bench, num_threads);
benchmark_strong_copy_with_weak_contention<Ref<TestObject>>(bench,
num_threads);
}
+21 -27
View File
@@ -1,3 +1,4 @@
#include "cpu_work.hpp"
#include "thread_pipeline.hpp"
#include <latch>
@@ -19,24 +20,22 @@ int main() {
.warmup(100);
bench.run("Zero stage pipeline", [&] {
for (int i = 0; i < NUM_ITEMS; ++i) {
for (volatile int i = 0; i < BUSY_ITERS; i = i + 1) {
}
spend_cpu_cycles(BUSY_ITERS);
}
});
StaticThreadPipeline<std::latch *, WaitStrategy::WaitIfStageEmpty, 1>
pipeline(LOG_PIPELINE_SIZE);
ThreadPipeline<std::latch *> pipeline(WaitStrategy::WaitIfStageEmpty, {1},
LOG_PIPELINE_SIZE);
std::latch done{0};
// Stage 0 consumer thread
std::thread stage0_thread([&pipeline, &done]() {
for (;;) {
auto guard = pipeline.acquire<0, 0>();
auto guard = pipeline.acquire(0, 0);
for (auto &item : guard.batch) {
for (volatile int i = 0; i < BUSY_ITERS; i = i + 1) {
}
spend_cpu_cycles(BUSY_ITERS);
if (item == &done) {
return;
}
@@ -90,19 +89,18 @@ int main() {
.warmup(100);
for (int batch_size : {1, 4, 16, 64, 256}) {
StaticThreadPipeline<std::latch *, WaitStrategy::WaitIfStageEmpty, 1>
pipeline(LOG_PIPELINE_SIZE);
ThreadPipeline<std::latch *> pipeline(WaitStrategy::WaitIfStageEmpty, {1},
LOG_PIPELINE_SIZE);
std::latch done{0};
// Stage 0 consumer thread
std::thread stage0_thread([&pipeline, &done]() {
for (;;) {
auto guard = pipeline.acquire<0, 0>();
auto guard = pipeline.acquire(0, 0);
for (auto &item : guard.batch) {
for (volatile int i = 0; i < BUSY_ITERS; i = i + 1) {
}
spend_cpu_cycles(BUSY_ITERS);
if (item == &done) {
return;
}
@@ -144,8 +142,8 @@ int main() {
}
// Helper function for wait strategy benchmarks
auto benchmark_wait_strategy =
[]<WaitStrategy strategy>(const std::string &name,
auto benchmark_wait_strategy = [](WaitStrategy strategy,
const std::string &name,
ankerl::nanobench::Bench &bench) {
constexpr int LOG_PIPELINE_SIZE =
8; // Smaller buffer to increase contention
@@ -154,18 +152,16 @@ int main() {
constexpr int BUSY_ITERS =
10; // Light work to emphasize coordination overhead
StaticThreadPipeline<std::latch *, strategy, 1, 1> pipeline(
LOG_PIPELINE_SIZE);
ThreadPipeline<std::latch *> pipeline(strategy, {1, 1}, LOG_PIPELINE_SIZE);
std::latch done{0};
// Stage 0 worker
std::thread stage0_thread([&pipeline, &done]() {
for (;;) {
auto guard = pipeline.template acquire<0, 0>();
auto guard = pipeline.acquire(0, 0);
for (auto &item : guard.batch) {
for (volatile int i = 0; i < BUSY_ITERS; i = i + 1) {
}
spend_cpu_cycles(BUSY_ITERS);
if (item == &done)
return;
}
@@ -175,10 +171,9 @@ int main() {
// Stage 1 worker (final stage - always calls futex wake)
std::thread stage1_thread([&pipeline, &done]() {
for (;;) {
auto guard = pipeline.template acquire<1, 0>();
auto guard = pipeline.acquire(1, 0);
for (auto &item : guard.batch) {
for (volatile int i = 0; i < BUSY_ITERS; i = i + 1) {
}
spend_cpu_cycles(BUSY_ITERS);
if (item == &done)
return;
if (item)
@@ -224,12 +219,11 @@ int main() {
.relative(true)
.warmup(50);
benchmark_wait_strategy.template operator()<WaitStrategy::WaitIfStageEmpty>(
"WaitIfStageEmpty", bench);
benchmark_wait_strategy.template
operator()<WaitStrategy::WaitIfUpstreamIdle>("WaitIfUpstreamIdle", bench);
benchmark_wait_strategy.template operator()<WaitStrategy::Never>("Never",
benchmark_wait_strategy(WaitStrategy::WaitIfStageEmpty, "WaitIfStageEmpty",
bench);
benchmark_wait_strategy(WaitStrategy::WaitIfUpstreamIdle,
"WaitIfUpstreamIdle", bench);
benchmark_wait_strategy(WaitStrategy::Never, "Never", bench);
}
// TODO: Add more benchmarks for:
-14
View File
@@ -1,14 +0,0 @@
#include <nanobench.h>
#include "../src/loop_iterations.h"
int main() {
ankerl::nanobench::Bench bench;
bench.minEpochIterations(100000);
bench.run("volatile loop to " + std::to_string(loopIterations), [&] {
for (volatile int i = 0; i < loopIterations; i = i + 1)
;
});
return 0;
}
+531
View File
@@ -0,0 +1,531 @@
# Commit Processing Pipeline
## Overview
WeaselDB implements a high-performance 4-stage commit processing pipeline that transforms HTTP commit requests into durable transactions. The pipeline provides strict serialization where needed while maximizing throughput through batching and asynchronous processing.
## Architecture
The commit processing pipeline consists of four sequential stages, each running on a dedicated thread:
```
HTTP I/O Threads → [Sequence] → [Resolve] → [Persist] → [Release] → HTTP I/O Threads
```
### Pipeline Flow
1. **HTTP I/O Threads**: Parse and validate incoming commit requests
1. **Sequence Stage**: Assign sequential version numbers to commits
1. **Resolve Stage**: Validate preconditions and check for conflicts
1. **Persist Stage**: Write commits to durable storage and notify subscribers
1. **Release Stage**: Return connections to HTTP I/O threads for response handling
## Stage Details
### Stage 0: Sequence Assignment
**Thread**: `txn-sequence`
**Purpose**: Version assignment and request ID management
**Serialization**: **Required** - Must be single-threaded
**Responsibilities**:
- **For CommitEntry**: Check request_id against banned list, assign sequential version number if not banned, forward to resolve stage
- **For StatusEntry**: Add request_id to banned list, note current highest assigned version as upper bound for version range scanning
- Record version assignments for transaction tracking
**Why Serialization is Required**:
- Version numbers must be strictly sequential without gaps
- Banned list updates must be atomic with version assignment
- Status requests must get accurate upper bound on potential commit versions
**Request ID Banned List**:
- Purpose: Make transactions no longer in-flight and establish version upper bounds for status queries
- Lifecycle: Grows indefinitely until process restart (leader change)
- Removal: Only on process restart/leader change, which invalidates all old request IDs
**Current Implementation**:
```cpp
bool HttpHandler::process_sequence_batch(BatchType &batch) {
for (auto &entry : batch) {
if (std::holds_alternative<ShutdownEntry>(entry)) {
return true; // Shutdown signal
}
// TODO: Pattern match on CommitEntry vs StatusEntry
// TODO: Implement sequence assignment logic for each type
}
return false; // Continue processing
}
```
### Stage 1: Precondition Resolution
**Thread**: `txn-resolve`
**Purpose**: Validate preconditions and detect conflicts
**Serialization**: **Required** - Must be single-threaded
**Responsibilities**:
- **For CommitEntry**: Check preconditions against in-memory recent writes set, add writes to recent writes set if accepted
- **For StatusEntry**: N/A (transferred to status threadpool after sequence stage)
- Mark failed commits with failure information (including which preconditions failed)
**Why Serialization is Required**:
- Must maintain consistent view of in-memory recent writes set
- Conflict detection requires atomic evaluation of all preconditions against recent writes
- Recent writes set updates must be synchronized
**Transaction State Transitions**:
- **Assigned Version** (from sequence) → **Semi-committed** (resolve accepts) → **Committed** (persist completes)
- Failed transactions continue through pipeline with failure information for client response
**Current Implementation**:
```cpp
bool HttpHandler::process_resolve_batch(BatchType &batch) {
// TODO: Implement precondition resolution logic:
// 1. For CommitEntry: Check preconditions against in-memory recent writes set
// 2. If accepted: Add writes to in-memory recent writes set, mark as semi-committed
// 3. If failed: Mark with failure info (which preconditions failed)
// 4. For StatusEntry: N/A (already transferred to status threadpool)
}
```
### Stage 2: Transaction Persistence
**Thread**: `txn-persist`
**Purpose**: Write semi-committed transactions to durable storage
**Serialization**: **Required** - Must mark batches durable in order
**Responsibilities**:
- **For CommitEntry**: Apply operations to persistent storage, update committed version high water mark, generate success response JSON
- **For StatusEntry**: N/A (empty husk, connection transferred to status threadpool after sequence stage)
- Generate durability events for `/v1/subscribe` when committed version advances
- Batch multiple commits for efficient persistence operations
**Why Serialization is Required**:
- Batches must be marked durable in sequential version order
- High water mark updates must reflect strict ordering of committed versions
- Ensures consistency guarantees across all endpoints
**Committed Version High Water Mark**:
- Global atomic value tracking highest durably committed version
- Updated after each batch commits: set to highest version in the batch
- Read by `/v1/version` endpoint using atomic seq_cst reads
- Enables `/v1/subscribe` durability events when high water mark advances
**Batching Strategy**:
- Multiple semi-committed transactions can be persisted in a single batch
- High water mark updated once per batch to highest version in that batch
- See `persistence.md` for detailed persistence design
**Current Implementation**:
```cpp
bool HttpHandler::process_persist_batch(BatchType &batch) {
// For CommitEntry: Apply operations to persistent storage, update high water mark, generate response JSON
// For StatusEntry: N/A (empty husk, connection transferred to status threadpool)
// Generate durability events for /v1/subscribe when committed version advances
// Semi-committed transactions are retried until durable or leader fails
}
```
### Stage 3: Connection Release
**Threads**: Multiple `txn-release` threads (configurable)
**Purpose**: Return connections to HTTP server for client response
**Serialization**: Not required - Independent connection handling
**Responsibilities**:
- Return processed connections to HTTP server for all request types
- Connection carries response data (success/failure) and status information
- Trigger response transmission to clients
**Response Handling**:
- **CommitRequests**: Response JSON generated by persist stage (success with version, or failure with conflicting preconditions from resolve stage)
- **StatusRequests**: Response generated by separate status threadpool (connection transferred after sequence stage)
- Failed transactions carry failure information through entire pipeline for proper client response
**Implementation**:
```cpp
bool HttpHandler::process_release_batch(BatchType &batch) {
// Stage 3: Connection release
for (auto &conn : batch) {
if (!conn) {
return true; // Shutdown signal
}
// Connection is server-owned - respond to client and connection
// remains managed by server's connection registry
// TODO: Implement response sending with new server-owned connection model
}
return false; // Continue processing
}
```
## Threading Model
### Thread Pipeline Configuration
```cpp
// 4-stage pipeline: sequence -> resolve -> persist -> release
// Pipeline with PipelineEntry variant instead of connection ownership transfer
StaticThreadPipeline<PipelineEntry, // Was: std::unique_ptr<Connection>
WaitStrategy::WaitIfUpstreamIdle, 1, 1, 1, 1>
commitPipeline{lg_size};
// Pipeline entry type for server-owned connection model
using PipelineEntry = std::variant<CommitEntry, StatusEntry, ShutdownEntry>;
```
### Thread Creation and Management
```cpp
HttpHandler() {
// Stage 0: Sequence assignment thread
sequenceThread = std::thread{[this]() {
pthread_setname_np(pthread_self(), "txn-sequence");
for (;;) {
auto guard = commitPipeline.acquire<0, 0>();
if (process_sequence_batch(guard.batch)) {
return; // Shutdown signal received
}
}
}};
// Similar pattern for resolve, persist, and release threads...
}
```
### Batch Processing
Each stage processes connections in batches using RAII guards:
```cpp
auto guard = commitPipeline.acquire<STAGE_NUM, 0>();
// Process batch
for (auto &conn : guard.batch) {
// Stage-specific processing
}
// Guard destructor automatically publishes batch to next stage
```
## Flow Control
### Pipeline Entry
Commit requests enter the pipeline via `HttpHandler::on_batch_complete()`:
```cpp
void HttpHandler::on_batch_complete(std::span<Connection*> batch) {
// Collect commit requests that passed basic validation for 4-stage pipeline processing
int commit_count = 0;
for (auto &conn : batch) {
if (conn && conn->user_data) {
auto *state = static_cast<HttpConnectionState *>(conn->user_data);
if (state->route == HttpRoute::POST_commit &&
state->commit_request &&
state->parsing_commit) {
commit_count++;
}
}
}
// Send commit requests to 4-stage pipeline in batch
if (commit_count > 0) {
auto guard = commitPipeline.push(commit_count, true);
// Move qualifying connections into pipeline
}
}
```
### Backpressure Handling
The pipeline implements natural backpressure:
- Fixed-size pipeline buffer causes I/O threads to block when pipeline is full
- This prevents unbounded memory growth under high load
- I/O threads blocking may impact accept() rate, but provides system-wide flow control
- `WaitIfUpstreamIdle` strategy balances latency vs throughput
- Ring buffer size (`lg_size = 16`) controls maximum queued batches
### Shutdown Coordination
Pipeline shutdown is coordinated by sending a single ShutdownEntry that flows through all stages:
```cpp
~HttpHandler() {
// Send single shutdown signal that flows through all pipeline stages
{
auto guard = commitPipeline.push(1, true);
guard.batch[0] = ShutdownEntry{}; // Single ShutdownEntry flows through all stages
}
// Join all pipeline threads
sequenceThread.join();
resolveThread.join();
persistThread.join();
releaseThread.join();
}
```
**Note**: Multiple entries would only be needed if stages had multiple threads, with each thread needing its own shutdown signal.
## Error Handling
### Stage-Level Error Handling
Each stage handles different entry types:
```cpp
// Pattern matching on pipeline entry variant
std::visit([&](auto&& entry) {
using T = std::decay_t<decltype(entry)>;
if constexpr (std::is_same_v<T, ShutdownEntry>) {
return true; // Signal shutdown
} else if constexpr (std::is_same_v<T, CommitEntry>) {
// Process commit entry
} else if constexpr (std::is_same_v<T, StatusEntry>) {
// Handle status entry (or skip if transferred)
}
}, pipeline_entry);
```
### Connection Error States
- Failed CommitEntries are passed through the pipeline with error information
- Downstream stages skip processing for error connections but forward them
- Error responses are sent when connection reaches release stage
- Server-owned connections ensure proper cleanup and response handling
### Pipeline Integrity
- ShutdownEntry signals shutdown to all stages
- Each stage checks for ShutdownEntry and returns true to signal shutdown
- RAII guards ensure entries are always published downstream
- No entries are lost even during error conditions
## Performance Characteristics
### Throughput Optimization
- **Batching**: Multiple connections processed per stage activation
- **Lock-Free Communication**: Ring buffer between stages
- **Minimal Context Switching**: Dedicated threads per stage
- **Arena Allocation**: Efficient memory management throughout pipeline
### Latency Optimization
- **Single-Pass Processing**: Each connection flows through all stages once
- **Streaming Design**: Stages process concurrently
- **Minimal Copying**: Request processing with server-owned connections
- **Direct Response**: Release stage triggers immediate response transmission
### Scalability Characteristics
- **Batch Size Tuning**: Ring buffer size controls memory vs latency tradeoff
- **Thread Affinity**: Dedicated threads reduce scheduling overhead
- **NUMA Awareness**: Can pin threads to specific CPU cores
## Configuration
### Pipeline Parameters
```cpp
private:
static constexpr int lg_size = 16; // Ring buffer size = 2^16 entries
// 4-stage pipeline configuration
StaticThreadPipeline<PipelineEntry,
WaitStrategy::WaitIfUpstreamIdle, 1, 1, 1, 1>
commitPipeline{lg_size};
```
### Tuning Considerations
- **Ring Buffer Size**: Larger buffers increase memory usage but improve batching
- **Wait Strategy**: `WaitIfUpstreamIdle` balances CPU usage vs latency
- **Thread Affinity**: OS scheduling vs explicit CPU pinning tradeoffs
## Pipeline Entry Types
The pipeline processes different types of entries using a variant/union type system instead of `std::unique_ptr<Connection>`:
### Pipeline Entry Variants
- **CommitEntry**: Contains connection reference/ID with CommitRequest and connection state
- **StatusEntry**: Contains connection reference/ID with StatusRequest (transferred to status threadpool after sequence)
- **ShutdownEntry**: Signals pipeline shutdown to all stages
- **Future types**: Pipeline design supports additional entry types
### Stage Processing by Type
| Stage | CommitEntry | StatusEntry | ShutdownEntry | Serialization |
|-------|-------------|-------------|---------------|---------------|
| **Sequence** | Check banned list, assign version | Add to banned list, transfer to status threadpool | Return true (shutdown) | **Required** |
| **Resolve** | Check preconditions, update recent writes | N/A (empty husk) | Return true (shutdown) | **Required** |
| **Persist** | Apply operations, update high water mark | N/A (empty husk) | Return true (shutdown) | **Required** |
| **Release** | Return connection to HTTP threads | N/A (empty husk) | Return true (shutdown) | Not required (multiple threads) |
## API Endpoint Integration
### `/v1/commit` - Transaction Submission
**Pipeline Interaction**: Full pipeline traversal as CommitEntry
#### Request Processing Flow
1. **HTTP I/O Thread Processing** (`src/http_handler.cpp:210-273`):
```cpp
void HttpHandler::handlePostCommit(Connection &conn, HttpConnectionState &state) {
// Parse and validate anything that doesn't need serialization:
// - JSON parsing and CommitRequest construction
// - Basic validation: leader_id check, operation format validation
// - Check that we have at least one operation
// If validation fails, send error response immediately and return
// If validation succeeds, connection will enter pipeline in on_batch_complete()
}
```
1. **Pipeline Entry**: Successfully parsed connections enter pipeline as CommitEntry (containing the connection with CommitRequest)
1. **Pipeline Processing**:
- **Sequence**: Check banned list → assign version (or reject)
- **Resolve**: Check preconditions against in-memory recent writes → mark semi-committed (or failed with conflict details)
- **Persist**: Apply operations → mark committed, update high water mark
- **Release**: Return connection with response data
1. **Response Generation**: Based on pipeline results
- **Success**: `{"status": "committed", "version": N, "leader_id": "...", "request_id": "..."}`
- **Failure**: `{"status": "not_committed", "conflicts": [...], "version": N, "leader_id": "..."}`
### `/v1/status` - Commit Status Lookup
**Pipeline Interaction**: StatusEntry through sequence stage only
#### Request Processing Flow
1. **HTTP I/O Thread Processing**:
```cpp
void HttpHandler::handleGetStatus(Connection &conn, const HttpConnectionState &state) {
// Extract request_id from URL and min_version from query params
// Create StatusEntry for pipeline processing
}
```
1. **Pipeline Processing**:
- **Sequence Stage**: StatusEntry adds request_id to banned list, establishes version scanning range, transfers connection to status threadpool
- **Subsequent Stages**: Empty StatusEntry husk flows through resolve/persist/release as no-op
1. **Status Lookup Logic**:
- Version range determined in sequence stage (min_version parameter to version upper bound)
- Actual S3 scanning performed by separate status threadpool outside the pipeline
- Return "committed" with version if found, "not_found" if not found in scanned range
### `/v1/subscribe` - Real-time Transaction Stream
**Pipeline Integration**: Consumes events from resolve and persist stages
#### Event Sources
- **Resolve Stage**: Semi-committed transactions (accepted preconditions) for low-latency streaming
- **Persist Stage**: Durability events when committed version high water mark advances
#### Current Implementation
```cpp
void HttpHandler::handleGetSubscribe(Connection &conn, const HttpConnectionState &state) {
// TODO: Parse query parameters (after, durable)
// TODO: Establish Server-Sent Events stream
// TODO: Subscribe to resolve stage (semi-committed) and persist stage (durability) events
}
```
### `/v1/version` - Version Information
**Pipeline Integration**: Direct atomic read, no pipeline interaction
```cpp
// TODO: Implement direct atomic read of committed version high water mark
// No pipeline interaction needed - seq_cst atomic read
// Leader ID is process-lifetime constant
```
**Response**: `{"version": <high_water_mark>, "leader_id": "<process_leader_id>"}`
## Integration Points
### HTTP Handler Integration
The pipeline integrates with the HTTP handler at two points:
1. **Entry**: `on_batch_complete()` feeds connections into sequence stage
1. **Exit**: Release stage responds to clients with server-owned connections
### Persistence Layer Integration
The persist stage interfaces with:
- **S3 Backend**: Batch writes for durability (see `persistence.md`)
- **Subscriber System**: Real-time change stream notifications
- **Metrics System**: Transaction throughput and latency tracking
### Database State Integration
- **Sequence Stage**: Updates version number generator
- **Resolve Stage**: Queries current database state for precondition validation
- **Persist Stage**: Applies mutations to authoritative database state
## Future Optimizations
### Potential Enhancements
1. **Dynamic Thread Counts**: Make resolve and release thread counts configurable
1. **NUMA Optimization**: Pin pipeline threads to specific CPU cores
1. **Batch Size Tuning**: Dynamic batch size based on load
1. **Stage Bypassing**: Skip resolve stage for transactions without preconditions
1. **Persistence Batching**: Aggregate multiple commits into larger S3 writes
### Monitoring and Observability
1. **Stage Metrics**: Throughput, latency, and queue depth per stage
1. **Error Tracking**: Error rates and types by stage
1. **Resource Utilization**: CPU and memory usage per pipeline thread
1. **Flow Control Events**: Backpressure and stall detection
## Implementation Status
### Current State
- ✅ Pipeline structure implemented with 4 stages
- ✅ Thread creation and management
- ✅ RAII batch processing
- ✅ Error handling framework
- ✅ Shutdown coordination
### TODO Items
- ⏳ Sequence assignment logic implementation
- ⏳ Precondition resolution implementation
- ⏳ S3 persistence batching implementation
- ⏳ Subscriber notification system
- ⏳ Performance monitoring and metrics
- ⏳ Configuration tuning and optimization
+39 -8
View File
@@ -18,9 +18,7 @@ Controls server networking, threading, and request handling behavior.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `bind_address` | string | `"127.0.0.1"` | IP address to bind the server to |
| `port` | integer | `8080` | Port number to listen on |
| `unix_socket_path` | string | `""` (empty) | Unix domain socket path. If specified, takes precedence over TCP |
| `interfaces` | array of objects | TCP on 127.0.0.1:8080 | Network interfaces to listen on. Each interface can be TCP or Unix socket |
| `max_request_size_bytes` | integer | `1048576` (1MB) | Maximum size for incoming requests. Requests exceeding this limit receive a `413 Content Too Large` response |
| `io_threads` | integer | `1` | Number of I/O threads for handling connections and network events |
| `epoll_instances` | integer | `io_threads` | Number of epoll instances to reduce kernel contention (max: io_threads). Lower values allow multiple threads per epoll for better load balancing, higher values reduce contention |
@@ -30,13 +28,15 @@ Controls server networking, threading, and request handling behavior.
### Commit Configuration (`[commit]`)
Controls behavior of the `/v1/commit` endpoint and request ID management.
Controls behavior of the `/v1/commit` endpoint, request ID management, and commit pipeline threading.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `min_request_id_length` | integer | `20` | Minimum length required for client-provided `request_id` fields to ensure sufficient entropy for collision avoidance |
| `request_id_retention_hours` | integer | `24` | How long to retain request IDs in memory for `/v1/status` queries. Longer retention reduces the chance of `log_truncated` responses |
| `request_id_retention_versions` | integer | `100000000` | Minimum number of versions to retain request IDs for, regardless of time. Provides additional protection against `log_truncated` responses |
| `pipeline_wait_strategy` | string | `"WaitIfUpstreamIdle"` | Wait strategy for the commit pipeline. `"WaitIfStageEmpty"` = block when individual stages are empty (safe for shared CPUs), `"WaitIfUpstreamIdle"` = block only when all upstream stages are idle (requires dedicated cores, highest throughput), `"Never"` = never block, busy-wait continuously (requires dedicated cores, lowest latency) |
| `pipeline_release_threads` | integer | `1` | Number of threads in the release stage (final stage of commit pipeline). Higher values increase parallelism for connection release and response transmission |
### Subscription Configuration (`[subscription]`)
@@ -47,16 +47,25 @@ Controls behavior of the `/v1/subscribe` endpoint and SSE streaming.
| `max_buffer_size_bytes` | integer | `10485760` (10MB) | Maximum amount of unconsumed data to buffer for slow subscribers. Connections are closed if this limit is exceeded |
| `keepalive_interval_seconds` | integer | `30` | Interval between keepalive comments in the Server-Sent Events stream to prevent idle timeouts on network proxies |
### Benchmark Configuration (`[benchmark]`)
Controls benchmarking and health check behavior.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `ok_resolve_iterations` | integer | `4000` | CPU-intensive loop iterations for `/ok` requests in resolve stage. 0 = health check only, 4000 = default benchmark load (~650ns, 1M req/s) |
## Example Configuration
```toml
# WeaselDB Configuration File
[server]
# Network configuration
bind_address = "0.0.0.0"
port = 8080
# unix_socket_path = "weaseldb.sock" # Alternative to TCP
# Network interfaces - can specify multiple TCP and/or Unix socket interfaces
interfaces = [
{ type = "tcp", address = "0.0.0.0", port = 8080 },
# { type = "unix", path = "weaseldb.sock" }, # Alternative Unix socket
]
# Performance tuning
max_request_size_bytes = 2097152 # 2MB
@@ -70,10 +79,15 @@ read_buffer_size = 32768 # 32KB
min_request_id_length = 32
request_id_retention_hours = 48
request_id_retention_versions = 50000
pipeline_wait_strategy = "WaitIfUpstreamIdle" # Options: "WaitIfStageEmpty", "WaitIfUpstreamIdle", "Never"
pipeline_release_threads = 4 # Default: 1, increase for higher throughput
[subscription]
max_buffer_size_bytes = 52428800 # 50MB
keepalive_interval_seconds = 15
[benchmark]
ok_resolve_iterations = 10000 # Higher load for performance testing
```
## Configuration Loading
@@ -91,18 +105,27 @@ WeaselDB uses the `toml11` library for configuration parsing with robust error h
These configuration parameters directly affect server and API behavior:
**Server Performance:**
- **`io_threads`**: Controls parallelism for both accepting new connections and I/O processing. Should typically match CPU core count for optimal performance
- **`event_batch_size`**: Larger batches reduce syscall overhead but may increase latency under light load
- **`max_connections`**: Prevents resource exhaustion by limiting concurrent connections
**Request Handling:**
- **`max_request_size_bytes`**: Determines when `/v1/commit` returns `413 Content Too Large`
- **`min_request_id_length`**: Validates `request_id` fields in `/v1/commit` requests for sufficient entropy
**Request ID Management:**
- **`request_id_retention_*`**: Affects availability of data for `/v1/status` queries and likelihood of `log_truncated` responses
**Commit Pipeline Performance:**
- **`pipeline_wait_strategy`**: Controls CPU usage vs latency tradeoff in commit processing. `WaitIfStageEmpty` is safest for shared CPUs, `WaitIfUpstreamIdle` provides highest throughput with dedicated cores, `Never` provides lowest latency but uses 100% CPU
- **`pipeline_release_threads`**: Determines parallelism in the final stage of commit processing. More threads can improve throughput when processing many concurrent requests
**Subscription Streaming:**
- **`max_buffer_size_bytes`**: Controls when `/v1/subscribe` connections are terminated due to slow consumption
- **`keepalive_interval_seconds`**: Frequency of keepalive comments in `/v1/subscribe` streams
@@ -111,6 +134,7 @@ These configuration parameters directly affect server and API behavior:
The configuration system includes comprehensive validation with specific bounds checking:
### Server Configuration Limits
- **`port`**: Must be between 1 and 65535
- **`max_request_size_bytes`**: Must be > 0 and ≤ 100MB
- **`io_threads`**: Must be between 1 and 1000
@@ -118,26 +142,33 @@ The configuration system includes comprehensive validation with specific bounds
- **`max_connections`**: Must be between 0 and 100000 (0 = unlimited)
### Commit Configuration Limits
- **`min_request_id_length`**: Must be between 8 and 256 characters
- **`request_id_retention_hours`**: Must be between 1 and 8760 hours (1 year)
- **`request_id_retention_versions`**: Must be > 0
- **`pipeline_wait_strategy`**: Must be one of: `"WaitIfStageEmpty"`, `"WaitIfUpstreamIdle"`, or `"Never"`
- **`pipeline_release_threads`**: Must be between 1 and 64
### Subscription Configuration Limits
- **`max_buffer_size_bytes`**: Must be > 0 and ≤ 1GB
- **`keepalive_interval_seconds`**: Must be between 1 and 3600 seconds (1 hour)
### Cross-Validation
- Warns if `max_request_size_bytes` > `max_buffer_size_bytes` (potential buffering issues)
## Configuration Management
### Code Integration
- **Configuration Structure**: Defined in `src/config.hpp` with structured types
- **Parser Implementation**: Located in `src/config.cpp` using template-based parsing
- **Default Values**: Embedded as struct defaults for compile-time initialization
- **Runtime Usage**: Configuration passed to server components during initialization
### Development Guidelines
- **New Parameters**: Add to appropriate struct in `src/config.hpp`
- **Validation**: Include bounds checking in `ConfigParser::validate_config()`
- **Documentation**: Update this file when adding new configuration options
+24 -10
View File
@@ -1,25 +1,39 @@
# WeaselDB Configuration File
# See config.md for complete documentation of all configuration options
[server]
bind_address = "127.0.0.1"
port = 8080
# Maximum request size in bytes (for 413 Content Too Large responses)
# Network interfaces where WeaselDB will accept connections
# Options: TCP (address + port) or Unix domain sockets (path)
# For production, use TCP. For local testing, consider Unix sockets for better performance
interfaces = [
{ type = "tcp", address = "127.0.0.1", port = 8080 }
]
# Maximum size allowed for incoming requests (larger requests are rejected)
# Increase if you need to handle very large transaction payloads
max_request_size_bytes = 1048576 # 1MB
# Number of I/O threads for handling connections and network events
# Number of worker threads handling network connections
# Start with 1, increase if CPU usage is high under load
io_threads = 1
# Event batch size for epoll processing
# Internal network processing batch size
# Higher values may improve throughput at cost of latency
event_batch_size = 32
[commit]
# Minimum length for request_id to ensure sufficient entropy
# Required minimum length for transaction request IDs
# Longer IDs reduce chance of accidental duplicates across clients
min_request_id_length = 20
# How long to retain request IDs for /v1/status queries (hours)
# How long to keep transaction status information available (hours)
# Used by status API to look up the outcome of completed transactions
request_id_retention_hours = 24
# Minimum number of versions to retain request IDs
# Alternative retention policy: keep transaction status for at least this many database versions
# Ensures status lookups work even during periods of low database activity
request_id_retention_versions = 100000000
[subscription]
# Maximum buffer size for unconsumed data in /v1/subscribe (bytes)
# Memory limit for buffering change stream data per subscriber (bytes)
# Subscribers that fall behind will be disconnected when this limit is reached
# See api.md for details on the subscription streaming API
max_buffer_size_bytes = 10485760 # 10MB
# Interval for sending keepalive comments to prevent idle timeouts (seconds)
# How often to send keep-alive messages to streaming subscribers (seconds)
# Prevents network timeouts during periods of no database activity
keepalive_interval_seconds = 30
+197 -122
View File
@@ -3,15 +3,15 @@
## Table of Contents
1. [Project Overview](#project-overview)
2. [Quick Start](#quick-start)
3. [Architecture](#architecture)
4. [Development Guidelines](#development-guidelines)
5. [Common Patterns](#common-patterns)
6. [Reference](#reference)
1. [Quick Start](#quick-start)
1. [Architecture](#architecture)
1. [Development Guidelines](#development-guidelines)
1. [Common Patterns](#common-patterns)
1. [Reference](#reference)
**See also:** [style.md](style.md) for comprehensive C++ coding standards and conventions.
**IMPORTANT:** Read [style.md](style.md) first - contains mandatory C++ coding standards, threading rules, and testing guidelines that must be followed for all code changes.
---
______________________________________________________________________
## Project Overview
@@ -22,11 +22,21 @@ WeaselDB is a high-performance write-side database component designed for system
- **Ultra-fast arena allocation** (~1ns vs ~20-270ns for malloc)
- **High-performance JSON parsing** with streaming support and SIMD optimization
- **Multi-threaded networking** using multiple epoll instances with unified I/O thread pool
- **Multi-stage commit pipeline** with serial processing for consistency and parallel I/O for performance
- **Non-blocking metrics system** with try-lock optimization preventing pipeline stalls
- **Configurable epoll instances** to eliminate kernel-level contention
- **Zero-copy design** throughout the pipeline
- **Optimized memory management** with arena allocation and efficient copying
- **Factory pattern safety** ensuring correct object lifecycle management
---
### Design Philosophy
**"Two machines once you've mastered one"** - Optimize aggressively for single-machine performance before distributing. Most systems prematurely scale horizontally and never fully utilize their hardware. How are you supposed to horizontally scale strict serializability anyway?
**Boring formats, fast implementations** - Use standard data formats (JSON, HTTP, base64) with heavily optimized parsing. Universal compatibility without sacrificing performance.
**Read/write separation** - Fan out reads from the single write stream (persist stage to many subscribers), with true horizontal scaling via S3 for historical data. Keep writes simple and fast.
______________________________________________________________________
## Quick Start
@@ -43,44 +53,64 @@ ninja
### Testing & Development
**Run all tests:**
```bash
ninja test # or ctest
```
**Individual targets:**
- `./test_arena_allocator` - Arena allocator unit tests
- `./test_arena` - Arena allocator unit tests
- `./test_commit_request` - JSON parsing and validation tests
- `./test_http_handler` - HTTP protocol handling tests
- `./test_metric` - Metrics system tests
- `./test_api_url_parser` - API URL parsing tests
- `./test_reference` - Reference counting system tests
- `./test_server_connection_return` - Connection lifecycle tests
**Benchmarking:**
- `./bench_arena_allocator` - Memory allocation performance
- `./bench_arena` - Memory allocation performance
- `./bench_commit_request` - JSON parsing performance
- `./bench_cpu_work` - CPU work benchmarking utility
- `./bench_format_comparison` - String formatting performance
- `./bench_metric` - Metrics system performance
- `./bench_parser_comparison` - Compare vs nlohmann::json and RapidJSON
- `./bench_reference` - Reference counting performance
- `./bench_thread_pipeline` - Lock-free pipeline performance
**Debug tools:**
- `./debug_arena` - Analyze arena allocator behavior
**Load Testing:**
- `./load_tester` - A tool to generate load against the server for performance and stability analysis.
### Dependencies
**System requirements:**
- **weaseljson** - Must be installed system-wide (high-performance JSON parser)
- **gperf** - System requirement for perfect hash generation
**Auto-fetched:**
- **simdutf** - SIMD base64 encoding/decoding
- **toml11** - TOML configuration parsing
- **doctest** - Testing framework
- **nanobench** - Benchmarking library
- **nlohmann/json** - JSON library (used in benchmarks)
- **RapidJSON** - High-performance JSON library (used in benchmarks)
- **llhttp** - Fast HTTP parser
---
______________________________________________________________________
## Architecture
### Core Components
#### **Arena Allocator** (`src/arena_allocator.hpp`)
#### **Arena Allocator** (`src/arena.hpp`)
Ultra-fast memory allocator optimized for request/response patterns:
@@ -96,17 +126,19 @@ Ultra-fast memory allocator optimized for request/response patterns:
#### **Networking Layer**
**Server** (`src/server.{hpp,cpp}`):
- **High-performance multi-threaded networking** using multiple epoll instances with unified I/O thread pool
- **Configurable epoll instances** to eliminate kernel-level epoll_ctl contention (default: 2, max: io_threads)
- **Round-robin thread-to-epoll assignment** distributes I/O threads across epoll instances
- **Connection distribution** keeps accepted connections on same epoll, returns via round-robin
- **Factory pattern construction** via `Server::create()` ensures proper shared_ptr semantics
- **Factory pattern construction** via `Server::create()` ensures you can only get a `Ref<Server>`
- **Safe shutdown mechanism** with async-signal-safe shutdown() method
- **Connection ownership management** with automatic cleanup on server destruction
- **Pluggable protocol handlers** via ConnectionHandler interface
- **EPOLL_EXCLUSIVE** on listen socket across all epoll instances prevents thundering herd
**Connection** (`src/connection.{hpp,cpp}`):
- **Efficient per-connection state management** with arena-based memory allocation
- **Safe ownership transfer** between server threads and protocol handlers
- **Automatic cleanup** on connection closure or server shutdown
@@ -114,12 +146,13 @@ Ultra-fast memory allocator optimized for request/response patterns:
- **Protocol-specific data:** `user_data` `void*` for custom handler data
**ConnectionHandler Interface** (`src/connection_handler.hpp`):
- **Abstract protocol interface** decoupling networking from application logic
- **Ownership transfer support** allowing handlers to take connections for async processing
- **Streaming data processing** with partial message handling
- **Connection lifecycle hooks** for initialization and cleanup
#### **Thread Pipeline** (`src/ThreadPipeline.h`)
#### **Thread Pipeline** (`src/thread_pipeline.hpp`)
A high-performance, multi-stage, lock-free pipeline for inter-thread communication.
@@ -131,6 +164,7 @@ A high-performance, multi-stage, lock-free pipeline for inter-thread communicati
#### **Parsing Layer**
**JSON Commit Request Parser** (`src/json_commit_request_parser.{hpp,cpp}`):
- **High-performance JSON parser** using `weaseljson` library
- **Streaming parser support** for incremental parsing of network data
- **gperf-optimized token recognition** for fast JSON key parsing
@@ -140,6 +174,7 @@ A high-performance, multi-stage, lock-free pipeline for inter-thread communicati
- **Zero hash collisions** for known JSON tokens eliminates branching
**Parser Interface** (`src/commit_request_parser.hpp`):
- **Abstract base class** for commit request parsers
- **Format-agnostic parsing interface** supporting multiple serialization formats
- **Streaming and one-shot parsing modes**
@@ -148,15 +183,40 @@ A high-performance, multi-stage, lock-free pipeline for inter-thread communicati
#### **Data Model**
**Commit Request Data Model** (`src/commit_request.hpp`):
- **Format-agnostic data structure** for representing transactional commits
- **Arena-backed string storage** with efficient memory management
- **Move-only semantics** for optimal performance
- **Builder pattern** for constructing commit requests
- **Zero-copy string views** pointing to arena-allocated memory
- **String views** pointing to arena-allocated memory to avoid unnecessary copying
#### **Metrics System** (`src/metric.{hpp,cpp}`)
**High-Performance Metrics Implementation:**
- **Thread-local counters/histograms** with single writer for performance
- **Global gauges** with lock-free atomic CAS operations for multi-writer scenarios
- **SIMD-optimized histogram bucket updates** using AVX instructions for high throughput
- **Arena allocator integration** for efficient memory management during rendering
**Threading Model:**
- **Counters**: Per-thread storage, single writer, atomic write in `Counter::inc()`, atomic read in render thread
- **Histograms**: Per-thread storage, single writer, per-histogram mutex serializes all access (observe and render)
- **Gauges**: Lock-free atomic operations using `std::bit_cast` for double precision
- **Thread cleanup**: Automatic accumulation of thread-local state into global state on destruction
**Prometheus Compatibility:**
- **Standard metric types** with proper label handling and validation
- **Bucket generation helpers** for linear/exponential histogram distributions
- **Callback-based metrics** for dynamic values
- **UTF-8 validation** using simdutf for label values
#### **Configuration & Optimization**
**Configuration System** (`src/config.{hpp,cpp}`):
- **TOML-based configuration** using `toml11` library
- **Structured configuration** with server, commit, and subscription sections
- **Default fallback values** for all configuration options
@@ -164,6 +224,7 @@ A high-performance, multi-stage, lock-free pipeline for inter-thread communicati
- See `config.md` for complete configuration documentation
**JSON Token Optimization** (`src/json_tokens.gperf`, `src/json_token_enum.hpp`):
- **Perfect hash table** generated by gperf for O(1) JSON key lookup
- **Compile-time token enumeration** for type-safe key identification
- **Minimal perfect hash** reduces memory overhead and improves cache locality
@@ -172,6 +233,7 @@ A high-performance, multi-stage, lock-free pipeline for inter-thread communicati
### Transaction Data Model
#### CommitRequest Structure
```
CommitRequest {
- request_id: Optional unique identifier
@@ -190,48 +252,54 @@ CommitRequest {
### Memory Management Model
#### Connection Ownership Lifecycle
1. **Creation**: Accept threads create connections, transfer to epoll as raw pointers
2. **Processing**: Network threads claim ownership by wrapping in unique_ptr
3. **Handler Transfer**: Handlers can take ownership for async processing via unique_ptr.release()
4. **Return Path**: Handlers use Server::release_back_to_server() to return connections
5. **Safety**: All transfers use weak_ptr to server for safe cleanup
6. **Cleanup**: RAII ensures proper resource cleanup in all scenarios
1. **Creation**: Server creates connections and stores them in registry
1. **Processing**: I/O threads access connections via registry lookup
1. **Handler Access**: Handlers receive Connection& references, server retains ownership
1. **Async Processing**: Handlers use WeakRef<Connection> for safe async access
1. **Safety**: Connection mutex synchronizes concurrent access between threads
1. **Cleanup**: RAII ensures proper resource cleanup when connections are destroyed
#### Arena Memory Lifecycle
1. **Request Processing**: Handler uses `conn->get_arena()` to allocate memory for parsing request data
2. **Response Generation**: Handler uses arena for temporary response construction (headers, JSON, etc.)
3. **Response Queuing**: Handler calls `conn->append_message()` which copies data to arena-backed message queue
4. **Response Writing**: Server writes all queued messages to socket via `writeBytes()`
1. **Request Processing**: Handler creates request-scoped arena for parsing request data
1. **Response Generation**: Handler uses same arena for response construction (headers, JSON, etc.)
1. **Response Queuing**: Handler calls `conn->send_response()` passing span + arena ownership
1. **Response Writing**: I/O thread writes messages to socket, arena freed after completion
> **Note**: Call `conn->reset()` periodically to reclaim arena memory. Best practice is after all outgoing bytes have been written.
#### Threading Model and EPOLLONESHOT
#### Threading Model and Server-Owned Connections
**EPOLLONESHOT Design Rationale:**
WeaselDB uses `EPOLLONESHOT` for all connection file descriptors to enable safe multi-threaded ownership transfer without complex synchronization:
**Server-Owned Connection Design:**
WeaselDB uses a server-owned connection model where the server retains ownership of all connections while providing safe concurrent access to handlers:
**Key Benefits:**
1. **Automatic fd disarming** - When epoll triggers an event, the fd is automatically removed from epoll monitoring
2. **Race-free ownership transfer** - Handlers can safely take connection ownership and move to other threads
3. **Zero-coordination async processing** - No manual synchronization needed between network threads and handler threads
1. **Simplified ownership** - Server always owns connections, eliminating complex ownership transfers
1. **Safe concurrent access** - Connection mutexes synchronize access between I/O threads and handlers
1. **WeakRef pattern** - Handlers use WeakRef<Connection> for safe async processing without ownership
**Threading Flow:**
1. **Event Trigger**: Network thread gets epoll event → connection auto-disarmed via ONESHOT
2. **Safe Transfer**: Handler can take ownership (`std::move(conn_ptr)`) with no epoll interference
3. **Async Processing**: Connection processed on handler thread while epoll cannot trigger spurious events
4. **Return & Re-arm**: `Server::receiveConnectionBack()` re-arms fd with `epoll_ctl(EPOLL_CTL_MOD)`
**Performance Trade-off:**
- **Cost**: One `epoll_ctl(MOD)` syscall per connection return (~100-200ns)
- **Benefit**: Eliminates complex thread synchronization and prevents race conditions
- **Alternative cost**: Manual `EPOLL_CTL_DEL`/`ADD` + locking would be significantly higher
1. **Event Trigger**: Network thread gets epoll event and processes data
1. **Handler Invocation**: Handler receives Connection& reference - server retains ownership
1. **Async Processing**: Handler obtains WeakRef<Connection> for safe background processing
1. **Connection Cleanup**: Server manages connection lifecycle including file descriptor operations
**Without EPOLLONESHOT risks:**
- Multiple threads processing same fd simultaneously
- Use-after-move when network thread accesses transferred connection
- Complex synchronization between epoll events and ownership transfers
**Performance Benefits:**
This design enables the async handler pattern where connections can be safely moved between threads for background processing while maintaining high performance and thread safety.
- **Reduced syscalls**: Eliminates epoll_ctl(MOD) calls needed for ownership transfer
- **Simplified synchronization**: Connection mutexes provide clear concurrent access patterns
- **Memory efficiency**: No unique_ptr overhead for ownership management
**Safe Async Processing:**
- WeakRef<Connection> prevents use-after-free in background threads
- Connection mutex ensures thread-safe access to connection state
- Server handles all file descriptor management automatically
This design provides high performance concurrent processing while maintaining thread safety through clear ownership boundaries and synchronization primitives.
### API Endpoints
@@ -240,14 +308,14 @@ The system implements a RESTful API. See [api.md](api.md) for comprehensive API
### Design Principles
1. **Performance-first** - Every component optimized for high throughput
2. **Scalable concurrency** - Multiple epoll instances eliminate kernel contention
3. **Memory efficiency** - Arena allocation eliminates fragmentation
4. **Zero-copy** - Minimize data copying throughout pipeline
5. **Streaming-ready** - Support incremental processing
6. **Type safety** - Compile-time validation where possible
7. **Resource management** - RAII and move semantics throughout
1. **Scalable concurrency** - Multiple epoll instances eliminate kernel contention
1. **Memory efficiency** - Arena allocation eliminates fragmentation
1. **Efficient copying** - Minimize unnecessary copies while accepting required ones
1. **Streaming-ready** - Support incremental processing
1. **Type safety** - Compile-time validation where possible
1. **Resource management** - RAII and move semantics throughout
---
______________________________________________________________________
## Development Guidelines
@@ -259,13 +327,13 @@ See [style.md](style.md) for comprehensive C++ coding standards and conventions.
- **Server Creation**: Always use `Server::create()` factory method - direct construction is impossible
- **Connection Creation**: Only the Server can create connections - no public constructor or factory method
- **Connection Ownership**: Use unique_ptr semantics for safe ownership transfer between components
- **Arena Allocator Pattern**: Always use `ArenaAllocator` for temporary allocations within request processing
- **Connection Ownership**: Server retains ownership, handlers use Connection& references
- **Arena Allocator Pattern**: Always use `Arena` for temporary allocations within request processing
- **String View Usage**: Prefer `std::string_view` over `std::string` when pointing to arena-allocated memory
- **Ownership Transfer**: Use `Server::release_back_to_server()` for returning connections to server from handlers
- **Async Processing**: Use `conn.get_weak_ref()` for safe background processing without ownership
- **JSON Token Lookup**: Use the gperf-generated perfect hash table in `json_tokens.hpp` for O(1) key recognition
- **Base64 Handling**: Always use simdutf for base64 encoding/decoding for performance
- **Thread Safety**: Connection ownership transfers are designed to be thread-safe with proper RAII cleanup
- **Thread Safety**: Connection mutexes provide safe concurrent access between threads
### Project Structure
@@ -278,20 +346,22 @@ See [style.md](style.md) for comprehensive C++ coding standards and conventions.
### Extension Points
#### Adding New Protocol Handlers
1. Inherit from `ConnectionHandler` in `src/connection_handler.hpp`
2. Implement `on_data_arrived()` with proper ownership semantics
3. Use connection's arena allocator for temporary allocations: `conn->get_arena()`
4. Handle partial messages and streaming protocols appropriately
5. Use `Server::release_back_to_server()` if taking ownership for async processing
6. Add corresponding test cases and integration tests
7. Consider performance implications of ownership transfers
1. Implement `on_data_arrived()` using Connection& reference parameter
1. Use connection's arena allocator for temporary allocations: `conn.get_arena()`
1. Handle partial messages and streaming protocols appropriately
1. Use `conn.get_weak_ref()` for safe async processing without ownership transfer
1. Add corresponding test cases and integration tests
1. Consider performance implications of concurrent access patterns
#### Adding New Parsers
1. Inherit from `CommitRequestParser` in `src/commit_request_parser.hpp`
2. Implement both streaming and one-shot parsing modes
3. Use arena allocation for all temporary string storage
4. Add corresponding test cases in `tests/`
5. Add benchmark comparisons in `benchmarks/`
1. Implement both streaming and one-shot parsing modes
1. Use arena allocation for all temporary string storage
1. Add corresponding test cases in `tests/`
1. Add benchmark comparisons in `benchmarks/`
### Performance Guidelines
@@ -299,6 +369,7 @@ See [style.md](style.md) for comprehensive C++ coding standards and conventions.
- **CPU**: Perfect hashing and SIMD operations are critical paths - avoid alternatives
- **I/O**: Streaming parser design supports incremental network data processing
- **Cache**: String views avoid copying, keeping data cache-friendly
- **Pipeline**: Serial stages must never block - only parallel release stage can take locks
### Configuration & Testing
@@ -307,13 +378,14 @@ See [style.md](style.md) for comprehensive C++ coding standards and conventions.
- **Build System**: CMake generates gperf hash tables at build time
- **Testing Guidelines**: See [style.md](style.md) for comprehensive testing standards including synchronization rules
---
______________________________________________________________________
## Common Patterns
### Factory Method Patterns
#### Server Creation
```cpp
// Server must be created via factory method
auto server = Server::create(config, handler);
@@ -324,59 +396,51 @@ auto server = Server::create(config, handler);
```
#### Connection Creation (Server-Only)
```cpp
// Only Server can create connections (using private friend method)
class Server {
private:
auto conn = Connection::createForServer(addr, fd, id, handler, weak_from_this());
};
// No public way to create connections - all these fail:
// auto conn = Connection::create(...); // ERROR: no such method
// Connection conn(addr, fd, id, handler, server); // ERROR: private constructor
// auto conn = std::make_unique<Connection>(...); // ERROR: private constructor
```
Only Server can create connections (using private constructor via friend access)
### ConnectionHandler Implementation Patterns
#### Simple Synchronous Handler
```cpp
class HttpHandler : public ConnectionHandler {
class HttpHandler : ConnectionHandler {
public:
void on_data_arrived(std::string_view data, std::unique_ptr<Connection>& conn_ptr) override {
void on_data_arrived(std::string_view data, Connection& conn) override {
// Parse HTTP request using connection's arena
ArenaAllocator& arena = conn_ptr->get_arena();
Arena& arena = conn.get_arena();
// Generate response
conn_ptr->append_message("HTTP/1.1 200 OK\r\n\r\nHello World");
conn.send_response("HTTP/1.1 200 OK\r\n\r\nHello World");
// Server retains ownership
}
};
```
#### Async Handler with Ownership Transfer
#### Async Handler with WeakRef
```cpp
class AsyncHandler : public ConnectionHandler {
class AsyncHandler : ConnectionHandler {
public:
void on_data_arrived(std::string_view data, std::unique_ptr<Connection>& conn_ptr) override {
// Take ownership for async processing
auto connection = std::move(conn_ptr); // conn_ptr is now null
void on_data_arrived(std::string_view data, Connection& conn) override {
// Get weak reference for async processing
auto weak_conn = conn.get_weak_ref();
work_queue.push([connection = std::move(connection)](std::string_view data) mutable {
// Process asynchronously
connection->append_message("Async response");
// Return ownership to server when done
Server::release_back_to_server(std::move(connection));
work_queue.push([weak_conn, data = std::string(data)]() {
// Process asynchronously - connection may be closed
if (auto conn_ref = weak_conn.lock()) {
conn_ref->send_response("Async response");
}
});
}
};
```
#### Batching Handler with User Data
```cpp
class BatchingHandler : public ConnectionHandler {
class BatchingHandler : ConnectionHandler {
public:
void on_connection_established(Connection &conn) override {
// Allocate some protocol-specific data and attach it to the connection
@@ -388,21 +452,20 @@ public:
delete static_cast<MyProtocolData*>(conn.user_data);
}
void on_data_arrived(std::string_view data,
std::unique_ptr<Connection> &conn_ptr) override {
void on_data_arrived(std::string_view data, Connection& conn) override {
// Process data and maybe store some results in the user_data
auto* proto_data = static_cast<MyProtocolData*>(conn_ptr->user_data);
auto* proto_data = static_cast<MyProtocolData*>(conn.user_data);
proto_data->process(data);
}
void on_batch_complete(std::span<std::unique_ptr<Connection>> batch) override {
void on_batch_complete(std::span<Connection *const> batch) override {
// Process a batch of connections
for (auto& conn_ptr : batch) {
if (conn_ptr) {
auto* proto_data = static_cast<MyProtocolData*>(conn_ptr->user_data);
for (auto* conn : batch) {
if (conn) {
auto* proto_data = static_cast<MyProtocolData*>(conn->user_data);
if (proto_data->is_ready()) {
// This connection is ready for the next stage, move it to the pipeline
pipeline_.push(std::move(conn_ptr));
// This connection is ready for the next stage, get weak ref for pipeline
pipeline_.push(conn->get_weak_ref());
}
}
}
@@ -414,20 +477,21 @@ private:
```
#### Streaming "yes" Handler
```cpp
class YesHandler : public ConnectionHandler {
class YesHandler : ConnectionHandler {
public:
void on_connection_established(Connection &conn) override {
// Write an initial "y\n"
conn.append_message("y\n");
conn.send_response("y\n");
}
void on_write_progress(std::unique_ptr<Connection> &conn) override {
if (conn->outgoingBytesQueued() == 0) {
void on_write_progress(Connection &conn) override {
if (conn.outgoing_bytes_queued() == 0) {
// Don't use an unbounded amount of memory
conn->reset();
conn.reset();
// Write "y\n" repeatedly
conn->append_message("y\n");
conn.send_response("y\n");
}
}
};
@@ -436,32 +500,35 @@ public:
### Memory Management Patterns
#### Arena-Based String Handling
```cpp
// Preferred: Zero-copy string view with arena allocation
std::string_view process_json_key(const char* data, ArenaAllocator& arena);
// Preferred: String view with arena allocation to minimize copying
std::string_view process_json_key(const char* data, Arena& arena);
// Avoid: Unnecessary string copies
std::string process_json_key(const char* data);
```
#### Safe Connection Ownership Transfer
#### Safe Async Connection Processing
```cpp
// In handler - take ownership for background processing
Connection* raw_conn = conn_ptr.release();
// In handler - get weak reference for background processing
auto weak_conn = conn.get_weak_ref();
// Process on worker thread
background_processor.submit([raw_conn]() {
background_processor.submit([weak_conn]() {
// Do work...
raw_conn->append_message("Background result");
// Return to server safely (handles server destruction)
Server::release_back_to_server(std::unique_ptr<Connection>(raw_conn));
if (auto conn_ref = weak_conn.lock()) {
conn_ref->send_response("Background result");
}
// Connection automatically cleaned up by server
});
```
### Data Construction Patterns
#### Builder Pattern Usage
```cpp
CommitRequest request = CommitRequestBuilder(arena)
.request_id("example-id")
@@ -471,39 +538,47 @@ CommitRequest request = CommitRequestBuilder(arena)
```
#### Error Handling Pattern
```cpp
enum class ParseResult { Success, InvalidJson, MissingField };
ParseResult parse_commit_request(const char* json, CommitRequest& out);
```
---
______________________________________________________________________
## Reference
### Build Targets
**Test Executables:**
- `test_arena_allocator` - Arena allocator functionality tests
- `test_arena` - Arena allocator functionality tests
- `test_commit_request` - JSON parsing and validation tests
- `test_metric` - Metrics system functionality tests
- Main server executable (compiled from `src/main.cpp`)
**Benchmark Executables:**
- `bench_arena_allocator` - Arena allocator performance benchmarks
- `bench_arena` - Arena allocator performance benchmarks
- `bench_commit_request` - JSON parsing performance benchmarks
- `bench_parser_comparison` - Comparison benchmarks vs nlohmann::json and RapidJSON
- `bench_metric` - Metrics system performance benchmarks
**Debug Tools:**
- `debug_arena` - Debug tool for arena allocator analysis
### Performance Characteristics
**Memory Allocation:**
- **~1ns allocation time** vs standard allocators
- **Bulk deallocation** eliminates individual free() calls
- **Optimized geometric growth** uses current block size for doubling strategy
- **Alignment-aware** allocation prevents performance penalties
**JSON Parsing:**
- **Streaming parser** handles large payloads efficiently
- **Incremental processing** suitable for network protocols
- **Arena storage** eliminates string allocation overhead
+2
View File
@@ -0,0 +1,2 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+2
View File
@@ -0,0 +1,2 @@
#define ANKERL_NANOBENCH_IMPLEMENT
#include <nanobench.h>
+20 -9
View File
@@ -16,7 +16,7 @@ The persistence thread receives commit batches from the main processing pipeline
The persistence thread collects commits into batches using two trigger conditions:
1. **Time Trigger**: `batch_timeout_ms` elapsed since batch collection started
2. **Size Trigger**: `batch_size_threshold` commits collected (can be exceeded by final commit)
1. **Size Trigger**: `batch_size_threshold` commits collected (can be exceeded by final commit)
**Flow Control**: When `max_in_flight_requests` reached, block until responses received. Batches in retry backoff count toward the in-flight limit, creating natural backpressure during failures.
@@ -25,10 +25,12 @@ The persistence thread collects commits into batches using two trigger condition
### 1. Batch Collection
**No In-Flight Requests** (no I/O to pump):
- Use blocking acquire to get first commit batch (can afford to wait)
- Process immediately (no batching delay)
**With In-Flight Requests** (I/O to pump in event loop):
- Check flow control: if at `max_in_flight_requests`, block for responses
- Collect commits using non-blocking acquire until trigger condition:
- Check for available commits (non-blocking)
@@ -97,9 +99,10 @@ The persistence thread collects commits into batches using two trigger condition
## Configuration Validation
**Required Constraints**:
- `batch_size_threshold` > 0 (must process at least one commit per batch)
- `max_in_flight_requests` > 0 (must allow at least one concurrent request)
- `max_in_flight_requests` <= 1000 (required for single-call recovery guarantee)
- `max_in_flight_requests` \<= 1000 (required for single-call recovery guarantee)
- `batch_timeout_ms` > 0 (timeout must be positive)
- `max_retry_attempts` >= 0 (zero disables retries)
- `retry_base_delay_ms` > 0 (delay must be positive if retries enabled)
@@ -123,16 +126,19 @@ WeaselDB's batched persistence design enables efficient recovery while maintaini
WeaselDB uses a **sequential batch numbering** scheme with **S3 atomic operations** to provide efficient crash recovery and split-brain prevention without external coordination services.
**Batch Numbering Scheme**:
- Batch numbers start at `2^64 - 1` and count downward: `18446744073709551615, 18446744073709551614, 18446744073709551613, ...`
- Each batch is stored as S3 object `batches/{batch_number:020d}` with zero-padding
- S3 lexicographic ordering on zero-padded numbers returns batches in ascending numerical order (latest batches first)
**Terminology**: Since batch numbers decrease over time, we use numerical ordering:
- "Older" batches = higher numbers (written first in time)
- "Newer" batches = lower numbers (written more recently)
- "Most recent" batches = lowest numbers (most recently written)
**Example**: If batches 100, 99, 98, 97 are written, S3 LIST returns them as:
```
batches/00000000000000000097 (newest, lowest batch number)
batches/00000000000000000098
@@ -142,6 +148,7 @@ batches/00000000000000000100 (oldest, highest batch number)
```
**Leadership and Split-Brain Prevention**:
- New persistence thread instances scan S3 to find the highest (oldest) available batch number
- Each batch write uses `If-None-Match="*"` to atomically claim the sequential batch number
- Only one instance can successfully claim each batch number, preventing split-brain scenarios
@@ -150,28 +157,32 @@ batches/00000000000000000100 (oldest, highest batch number)
**Recovery Scenarios**:
**Clean Shutdown**:
- All in-flight batches are drained to completion before termination
- Durability watermark accurately reflects all durable state
- No recovery required on restart
**Crash Recovery**:
1. **S3 Scan with Bounded Cost**: List S3 objects with prefix `batches/` and limit of 1000 objects
2. **Gap Detection**: Check for missing sequential batch numbers. WeaselDB never puts more than 1000 batches in flight concurrently, so a limit of 1000 is sufficient.
3. **Watermark Reconstruction**: Set durability watermark to the latest consecutive batch (scanning from highest numbers downward, until a gap)
4. **Leadership Transition**: Begin writing batches starting from next available batch number. Skip past any batch numbers already claimed in the durability watermark scan.
1. **Gap Detection**: Check for missing sequential batch numbers. WeaselDB never puts more than 1000 batches in flight concurrently, so a limit of 1000 is sufficient.
1. **Watermark Reconstruction**: Set durability watermark to the latest consecutive batch (scanning from highest numbers downward, until a gap)
1. **Leadership Transition**: Begin writing batches starting from next available batch number. Skip past any batch numbers already claimed in the durability watermark scan.
**Bounded Recovery Guarantee**: Since at most 1000 batches can be in-flight during a crash, any gap in the sequential numbering (indicating the durability watermark) must appear within the first 1000 S3 objects. This is because:
1. At most 1000 batches can be incomplete when crash occurs
2. S3 LIST returns objects in ascending numerical order (most recent batches first due to countdown numbering)
3. The first gap found represents the boundary between durable and potentially incomplete batches
4. S3 LIST operations have a maximum limit of 1000 objects per request
5. Therefore, scanning 1000 objects (the maximum S3 allows in one request) is sufficient to find this boundary
1. S3 LIST returns objects in ascending numerical order (most recent batches first due to countdown numbering)
1. The first gap found represents the boundary between durable and potentially incomplete batches
1. S3 LIST operations have a maximum limit of 1000 objects per request
1. Therefore, scanning 1000 objects (the maximum S3 allows in one request) is sufficient to find this boundary
This ensures **O(1) recovery time** regardless of database size, with at most **one S3 LIST operation** required.
**Recovery Protocol Detail**: Even with exactly 1000 batches in-flight, recovery works correctly:
**Example Scenario**: Batches 2000 down to 1001 (1000 batches) are in-flight when crash occurs
- Previous successful run had written through batch 2001
- Worst case: batch 2000 (oldest in-flight) fails, batches 1999 down to 1001 (newer) all succeed
- S3 LIST(limit=1000) returns: 1001, 1002, ..., 1998, 1999, 2001 (ascending numerical order)
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# Reproduce the threading performance report results.
# Run from the project root, e.g.:
# ./reproduce_threading_report.sh
set -euo pipefail
BUILD_DIR="build"
CONFIG="test_benchmark_config.toml"
DURATION=120
if [ ! -d "$BUILD_DIR" ]; then
echo "Error: build directory '$BUILD_DIR' not found. Build the project first." >&2
exit 1
fi
if [ ! -f "$CONFIG" ]; then
echo "Error: config '$CONFIG' not found. Run this script from the project root." >&2
exit 1
fi
if [ ! -x "$BUILD_DIR/weaseldb" ] || [ ! -x "$BUILD_DIR/load_tester" ]; then
echo "Error: required binaries not found in '$BUILD_DIR'. Build the project first." >&2
exit 1
fi
cd "$BUILD_DIR"
# Increase file descriptor limit for high concurrency. Best-effort only:
# it may fail if the hard limit is lower, especially in containers.
ulimit -n 65536 2>/dev/null || echo "Warning: could not raise ulimit -n (continuing)" >&2
# Clean up any leftover socket or server log from a previous run
rm -f weaseldb.sock server.log
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
echo ""
echo "=== Stopping server ==="
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
echo "=== Server log tail ==="
tail -50 server.log 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo "=== Starting WeaselDB server ==="
./weaseldb --config "../$CONFIG" > server.log 2>&1 &
SERVER_PID=$!
echo "Server PID: $SERVER_PID"
# Wait for server to be ready (unix socket created)
for i in {1..30}; do
if [ -S weaseldb.sock ]; then
echo "Server ready after $((i * 100))ms"
break
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "Server died unexpectedly"
cat server.log
exit 1
fi
sleep 0.1
done
if [ ! -S weaseldb.sock ]; then
echo "Error: server failed to create socket within 3 seconds" >&2
exit 1
fi
echo "=== Server log (first lines) ==="
head -30 server.log
echo ""
echo "=== Running load tester ==="
./load_tester \
--unix-socket weaseldb.sock \
--concurrency 2000 \
--requests-per-conn 500 \
--connect-threads 2 \
--network-threads 10 \
--duration "$DURATION" \
--stats-interval 1
echo ""
echo "=== Load test complete ==="
+250
View File
@@ -0,0 +1,250 @@
#include "api_url_parser.hpp"
#include <cassert>
#include <string_view>
namespace {
// RFC 3986 hex digit to value conversion
// Returns -1 for invalid hex digits
int hex_digit_to_value(char c) {
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
return -1;
}
// Decode percent-encoded sequence at src
// Returns decoded byte value, or -1 for malformed encoding
int decode_percent_sequence(const char *src) {
if (src[0] != '%')
return -1;
int high = hex_digit_to_value(src[1]);
int low = hex_digit_to_value(src[2]);
if (high == -1 || low == -1)
return -1;
return (high << 4) | low;
}
// Decode RFC 3986 percent-encoding in place (for path segments)
// Returns new length, or -1 for malformed encoding
int decode_path_segment(char *data, int length) {
char *write_pos = data;
const char *read_pos = data;
const char *end = data + length;
while (read_pos < end) {
if (*read_pos == '%') {
if (read_pos + 2 >= end)
return -1; // Incomplete sequence
int decoded = decode_percent_sequence(read_pos);
if (decoded == -1)
return -1; // Malformed sequence
*write_pos++ = static_cast<char>(decoded);
read_pos += 3;
} else {
*write_pos++ = *read_pos++;
}
}
return static_cast<int>(write_pos - data);
}
// Decode application/x-www-form-urlencoded in place (for query parameters)
// Handles + → space conversion, then percent-decoding
// Returns new length, or -1 for malformed encoding
int decode_query_value(char *data, int length) {
char *write_pos = data;
const char *read_pos = data;
const char *end = data + length;
while (read_pos < end) {
if (*read_pos == '+') {
*write_pos++ = ' ';
read_pos++;
} else if (*read_pos == '%') {
if (read_pos + 2 >= end)
return -1; // Incomplete sequence
int decoded = decode_percent_sequence(read_pos);
if (decoded == -1)
return -1; // Malformed sequence
*write_pos++ = static_cast<char>(decoded);
read_pos += 3;
} else {
*write_pos++ = *read_pos++;
}
}
return static_cast<int>(write_pos - data);
}
// A simplified helper to find a delimiter in a buffer
// Returns the position of the delimiter, or -1 if not found
int find_delimiter(const char *data, int length, char delimiter) {
for (int i = 0; i < length; ++i) {
if (data[i] == delimiter) {
return i;
}
}
return -1;
}
// Maps a string parameter key to its corresponding enum value.
// Unrecognized keys are ignored as per the design.
[[nodiscard]] std::optional<ApiParameterKey>
to_api_parameter_key(std::string_view key) {
if (key == "request_id")
return ApiParameterKey::RequestId;
if (key == "min_version")
return ApiParameterKey::MinVersion;
return std::nullopt;
}
// Parses the query string part of a URL (in-place decoding)
// Returns ParseResult::Success or ParseResult::MalformedEncoding
ParseResult parse_query_string(char *query_data, int query_length,
RouteMatch &match) {
int pos = 0;
while (pos < query_length) {
// Find end of current key=value pair
int pair_end = find_delimiter(query_data + pos, query_length - pos, '&');
if (pair_end == -1)
pair_end = query_length - pos;
// Find = separator within the pair
int eq_pos = find_delimiter(query_data + pos, pair_end, '=');
if (eq_pos == -1) {
// No value, skip this parameter
pos += pair_end + 1;
continue;
}
// Decode key and value in place
char *key_start = query_data + pos;
int key_length = eq_pos;
char *value_start = query_data + pos + eq_pos + 1;
int value_length = pair_end - eq_pos - 1;
// Decode value (query parameters use form encoding)
int decoded_value_length = decode_query_value(value_start, value_length);
if (decoded_value_length == -1) {
return ParseResult::MalformedEncoding;
}
// Check if this is a parameter we care about
std::string_view key_view(key_start, key_length);
if (auto key_enum = to_api_parameter_key(key_view)) {
match.params[static_cast<int>(*key_enum)] =
std::string_view(value_start, decoded_value_length);
}
pos += pair_end + 1;
}
return ParseResult::Success;
}
} // namespace
ParseResult ApiUrlParser::parse(std::string_view method, char *url_data,
int url_length, RouteMatch &result) {
assert(url_data != nullptr);
assert(url_length >= 0);
// Find query string separator
int query_start = find_delimiter(url_data, url_length, '?');
char *path_data = url_data;
int path_length = (query_start == -1) ? url_length : query_start;
char *query_data = (query_start == -1) ? nullptr : url_data + query_start + 1;
int query_length = (query_start == -1) ? 0 : url_length - query_start - 1;
// Parse and decode query string first
if (query_data && query_length > 0) {
ParseResult query_result =
parse_query_string(query_data, query_length, result);
if (query_result != ParseResult::Success) {
return query_result;
}
}
// Decode path segment (RFC 3986 percent-decoding)
int decoded_path_length = decode_path_segment(path_data, path_length);
if (decoded_path_length == -1) {
return ParseResult::MalformedEncoding;
}
std::string_view path(path_data, decoded_path_length);
// Route matching with decoded path
if (method == "GET") {
if (path == "/v1/version") {
result.route = HttpRoute::GetVersion;
return ParseResult::Success;
}
if (path == "/v1/subscribe") {
result.route = HttpRoute::GetSubscribe;
return ParseResult::Success;
}
if (path == "/v1/status") {
result.route = HttpRoute::GetStatus;
return ParseResult::Success;
}
if (path.starts_with("/v1/retention")) {
result.route = HttpRoute::GetRetention;
// Note: This matches both /v1/retention and /v1/retention/{id}
if (path.length() > 13) { // length of "/v1/retention"
std::string_view policy_id =
path.substr(14); // length of "/v1/retention/"
if (!policy_id.empty()) {
result.params[static_cast<int>(ApiParameterKey::PolicyId)] =
policy_id;
}
}
return ParseResult::Success;
}
if (path == "/metrics") {
result.route = HttpRoute::GetMetrics;
return ParseResult::Success;
}
if (path == "/ok") {
result.route = HttpRoute::GetOk;
return ParseResult::Success;
}
} else if (method == "POST") {
if (path == "/v1/commit") {
result.route = HttpRoute::PostCommit;
return ParseResult::Success;
}
} else if (method == "PUT") {
if (path.starts_with("/v1/retention/")) {
result.route = HttpRoute::PutRetention;
std::string_view policy_id = path.substr(14);
result.params[static_cast<int>(ApiParameterKey::PolicyId)] = policy_id;
return ParseResult::Success;
}
} else if (method == "DELETE") {
if (path.starts_with("/v1/retention/")) {
result.route = HttpRoute::DeleteRetention;
std::string_view policy_id = path.substr(14);
result.params[static_cast<int>(ApiParameterKey::PolicyId)] = policy_id;
return ParseResult::Success;
}
}
result.route = HttpRoute::NotFound;
return ParseResult::Success;
}
+104
View File
@@ -0,0 +1,104 @@
#pragma once
#include <array>
#include <optional>
#include <string_view>
/**
* @brief Defines all HTTP routes supported by the WeaselDB server.
*/
enum class HttpRoute {
GetVersion,
PostCommit,
GetSubscribe,
GetStatus,
PutRetention,
GetRetention,
DeleteRetention,
GetMetrics,
GetOk,
NotFound
};
/**
* @brief Defines unique keys for all known URL and query parameters in the API.
* @note This allows for O(1) lookup of parameter values in a fixed-size array.
*/
enum class ApiParameterKey {
// --- Query Parameters ---
RequestId,
MinVersion,
// --- URL Parameters ---
PolicyId,
// --- Sentinel for array size ---
Count
};
/**
* @brief A fixed-size array for storing parsed parameter values from a URL.
*
* It is indexed by the ApiParameterKey enum. The value is a string_view into
* the original URL string, making lookups allocation-free.
*/
using ApiParameters = std::array<std::optional<std::string_view>,
static_cast<size_t>(ApiParameterKey::Count)>;
/**
* @brief Result codes for URL parsing operations.
*/
enum class [[nodiscard]] ParseResult { Success, MalformedEncoding };
/**
* @brief Contains the complete, structured result of a successful URL parse.
*
* This struct is the output of the ApiUrlParser and contains everything
* a handler needs to process a request, with no further parsing required.
*/
struct RouteMatch {
/**
* @brief The specific API endpoint that was matched.
*/
HttpRoute route;
/**
* @brief A fixed-size array containing all parsed URL and query parameters.
*/
ApiParameters params;
};
/**
* @brief A parser that matches a URL against the fixed WeaselDB API.
*
* This class provides a single static method to parse a URL and method
* into a structured RouteMatch object. It is designed to be a high-performance,
* allocation-free parser with a simple interface.
*/
class ApiUrlParser {
public:
/**
* @brief Parses a URL and HTTP method against the known WeaselDB API
* endpoints.
*
* **Mutates in place**: This function performs RFC 3986 percent-decoding
* directly on the provided URL buffer. Path segments are decoded according
* to RFC 3986, while query parameters follow
* application/x-www-form-urlencoded rules (+ → space, then %XX → bytes).
*
* @param method The HTTP method of the request.
* @param url_data Mutable buffer containing the URL (will be modified
* in-place).
* @param url_length Length of the URL data in bytes.
* @param out_match Output parameter for the parsed route and parameters.
* @return ParseResult::Success on successful parsing,
* ParseResult::MalformedEncoding if the URL contains invalid percent-encoding
* sequences.
* @note On success, string_view parameters in out_match point into the
* decoded url_data buffer and remain valid while url_data is unchanged.
* @note On error, url_data contents are undefined and should not be used.
*/
[[nodiscard]] static ParseResult parse(std::string_view method,
char *url_data, int url_length,
RouteMatch &out_match);
};
+11 -43
View File
@@ -1,40 +1,11 @@
#include "arena_allocator.hpp"
#include "arena.hpp"
#include <cassert>
#include <iomanip>
#include <limits>
#include <vector>
ArenaAllocator::~ArenaAllocator() {
while (current_block_) {
Block *prev = current_block_->prev;
std::free(current_block_);
current_block_ = prev;
}
}
ArenaAllocator::ArenaAllocator(ArenaAllocator &&other) noexcept
: initial_block_size_(other.initial_block_size_),
current_block_(other.current_block_) {
other.current_block_ = nullptr;
}
ArenaAllocator &ArenaAllocator::operator=(ArenaAllocator &&other) noexcept {
if (this != &other) {
while (current_block_) {
Block *prev = current_block_->prev;
std::free(current_block_);
current_block_ = prev;
}
initial_block_size_ = other.initial_block_size_;
current_block_ = other.current_block_;
other.current_block_ = nullptr;
}
return *this;
}
void ArenaAllocator::reset() {
void Arena::reset() {
if (!current_block_) {
return;
}
@@ -63,8 +34,8 @@ void ArenaAllocator::reset() {
current_block_->offset = 0;
}
void *ArenaAllocator::realloc_raw(void *ptr, uint32_t old_size,
uint32_t new_size, uint32_t alignment) {
void *Arena::realloc_raw(void *ptr, uint32_t old_size, uint32_t new_size,
uint32_t alignment) {
if (ptr == nullptr) {
return allocate_raw(new_size, alignment);
}
@@ -77,11 +48,7 @@ void *ArenaAllocator::realloc_raw(void *ptr, uint32_t old_size,
assert(current_block_ &&
"realloc called with non-null ptr but no current block exists");
// Assert that offset is large enough (should always be true for
// valid callers)
assert(current_block_->offset >= old_size &&
"offset must be >= old_size for valid last allocation");
if (current_block_->offset >= old_size) {
// Check if this was the last allocation by comparing with expected location
char *expected_last_alloc_start =
current_block_->data() + current_block_->offset - old_size;
@@ -105,6 +72,7 @@ void *ArenaAllocator::realloc_raw(void *ptr, uint32_t old_size,
return new_size == 0 ? nullptr : ptr;
}
}
}
// Can't extend in place
if (new_size == 0) {
@@ -128,7 +96,7 @@ void *ArenaAllocator::realloc_raw(void *ptr, uint32_t old_size,
return new_ptr;
}
void ArenaAllocator::debug_dump(std::ostream &out, bool show_memory_map,
void Arena::debug_dump(std::ostream &out, bool show_memory_map,
bool show_content, size_t content_limit) const {
out << "=== Arena Debug Dump ===" << std::endl;
@@ -245,19 +213,19 @@ void ArenaAllocator::debug_dump(std::ostream &out, bool show_memory_map,
}
}
void ArenaAllocator::add_block(size_t size) {
void Arena::add_block(size_t size) {
Block *new_block = Block::create(size, current_block_);
current_block_ = new_block;
}
size_t ArenaAllocator::calculate_next_block_size(size_t required_size) const {
size_t Arena::calculate_next_block_size(size_t required_size) const {
size_t doubled_size = (current_block_ ? current_block_->size : 0) * 2;
doubled_size =
std::min<size_t>(doubled_size, std::numeric_limits<uint32_t>::max());
return std::max(required_size, doubled_size);
}
void ArenaAllocator::dump_memory_contents(std::ostream &out, const char *data,
void Arena::dump_memory_contents(std::ostream &out, const char *data,
size_t size) {
const int bytes_per_line = 16;
+309 -66
View File
@@ -9,6 +9,7 @@
#include <iostream>
#include <limits>
#include <new>
#include <span>
#include <type_traits>
#include <typeinfo>
#include <utility>
@@ -16,7 +17,7 @@
/**
* @brief A high-performance arena allocator for bulk allocations.
*
* ArenaAllocator provides extremely fast memory allocation (~1ns per
* Arena provides extremely fast memory allocation (~1ns per
* allocation) by allocating large blocks and serving allocations from them
* sequentially. It's designed for scenarios where many small objects need to be
* allocated and can all be deallocated together.
@@ -38,7 +39,7 @@
*
* ## Usage Examples:
* ```cpp
* ArenaAllocator arena(1024);
* Arena arena(1024);
* void* ptr = arena.allocate_raw(100);
* int* num = arena.construct<int>(42);
* arena.reset(); // Reuse arena memory
@@ -51,39 +52,36 @@
* - Move semantics transfer ownership of all blocks
*
* ## Thread Safety:
* ArenaAllocator is **not thread-safe** - concurrent access from multiple
* Arena is **not thread-safe** - concurrent access from multiple
* threads requires external synchronization. However, this design is
* intentional for performance reasons and the WeaselDB architecture ensures
* thread safety through ownership patterns:
*
* ### Safe Usage Patterns in WeaselDB:
* - **Per-Connection Instances**: Each Connection owns its own ArenaAllocator
* instance, accessed only by the thread that currently owns the connection
* - **Single Owner Principle**: Connection ownership transfers atomically
* between threads using unique_ptr, ensuring only one thread accesses the arena
* at a time
* - **Per-Connection Instances**: Each Connection owns its own Arena
* instance, accessed by its io thread
* - **Server Ownership**: Server retains connection ownership, handlers access
* arenas through Connection& references with proper mutex protection
*
* ### Thread Ownership Model:
* 1. **Network Thread**: Claims connection ownership, accesses arena for I/O
* buffers
* 2. **Handler Thread**: Can take ownership via unique_ptr.release(), uses
* arena for request parsing and response generation
* 3. **Background Thread**: Can receive ownership for async processing, uses
* arena for temporary data structures
* 4. **Return Path**: Connection (and its arena) safely returned via
* Server::release_back_to_server()
* 1. **I/O Thread**: Server owns connections, processes socket I/O events
* 2. **Handler Thread**: Receives Connection& reference, creates request-scoped
* arenas for parsing and response generation
* 3. **Pipeline Thread**: Can use WeakRef<Connection> for async processing,
* creates own arenas for temporary data structures
* 4. **Arena Lifecycle**: Request-scoped arenas moved to message queue, freed
* after I/O completion without holding connection mutex
*
* ### Why This Design is Thread-Safe:
* - **Exclusive Access**: Only the current owner thread should access the arena
* - **Transfer Points**: Ownership transfers happen at well-defined
* synchronization points with proper memory barriers.
* - **No Shared State**: Each arena is completely isolated - no shared data
* between different arena instances
* - **Request-Scoped**: Each request gets its own Arena instance for isolation
* - **Move Semantics**: Arenas transferred via move, avoiding shared access
* - **Deferred Cleanup**: Arena destruction deferred to avoid malloc contention
* while holding connection mutex
*
* @warning Do not share ArenaAllocator instances between threads. Use separate
* @warning Do not share Arena instances between threads. Use separate
* instances per thread or per logical unit of work.
*/
struct ArenaAllocator {
struct Arena {
private:
/**
* @brief Internal block structure for the intrusive linked list.
@@ -116,18 +114,16 @@ private:
*/
static Block *create(size_t size, Block *prev) {
if (size > std::numeric_limits<uint32_t>::max()) {
std::fprintf(
stderr,
"ArenaAllocator: Block size %zu exceeds maximum uint32_t value\n",
std::fprintf(stderr,
"Arena: Block size %zu exceeds maximum uint32_t value\n",
size);
std::abort();
}
void *memory = std::aligned_alloc(
alignof(Block), align_up(sizeof(Block) + size, alignof(Block)));
if (!memory) {
std::fprintf(
stderr,
"ArenaAllocator: Failed to allocate memory block of size %zu\n",
std::fprintf(stderr,
"Arena: Failed to allocate memory block of size %zu\n",
size);
std::abort();
}
@@ -141,7 +137,7 @@ private:
public:
/**
* @brief Construct an ArenaAllocator with the specified initial block size.
* @brief Construct an Arena with the specified initial block size.
*
* No memory is allocated until the first allocation request (lazy
* initialization). The initial block size is used for the first block and as
@@ -149,7 +145,7 @@ public:
*
* @param initial_size Size in bytes for the first block (default: 1024)
*/
explicit ArenaAllocator(size_t initial_size = 1024)
explicit Arena(size_t initial_size = 1024)
: initial_block_size_(initial_size), current_block_(nullptr) {}
/**
@@ -158,18 +154,36 @@ public:
* Traverses the intrusive linked list backwards from current_block_,
* freeing each block. This ensures no memory leaks.
*/
~ArenaAllocator();
~Arena() {
while (current_block_) {
Block *prev = current_block_->prev;
std::free(current_block_);
current_block_ = prev;
}
}
/// Copy construction is not allowed (would be expensive and error-prone)
ArenaAllocator(const ArenaAllocator &) = delete;
Arena(const Arena &) = delete;
/// Copy assignment is not allowed (would be expensive and error-prone)
ArenaAllocator &operator=(const ArenaAllocator &) = delete;
Arena &operator=(const Arena &) = delete;
/**
* @brief Move constructor - transfers ownership of all blocks.
* @param other The ArenaAllocator to move from (will be left empty)
*
* @param other The Arena to move from (will be left in a valid, empty state)
*
* @note Post-move state: The moved-from Arena is left in a valid state
* equivalent to a newly constructed Arena. All operations remain safe:
* - allocate_raw(), allocate(), construct() work normally
* - reset() is safe and well-defined (no-op on empty arena)
* - used_bytes(), total_bytes() return 0
* - Destructor is safe to call
*/
ArenaAllocator(ArenaAllocator &&other) noexcept;
Arena(Arena &&other) noexcept
: initial_block_size_(other.initial_block_size_),
current_block_(other.current_block_) {
other.current_block_ = nullptr;
}
/**
* @brief Move assignment operator - transfers ownership of all blocks.
@@ -177,10 +191,31 @@ public:
* Frees any existing blocks in this allocator before taking ownership
* of blocks from the other allocator.
*
* @param other The ArenaAllocator to move from (will be left empty)
* @param other The Arena to move from (will be left in a valid, empty state)
* @return Reference to this allocator
*
* @note Post-move state: The moved-from Arena is left in a valid state
* equivalent to a newly constructed Arena. All operations remain safe:
* - allocate_raw(), allocate(), construct() work normally
* - reset() is safe and well-defined (no-op on empty arena)
* - used_bytes(), total_bytes() return 0
* - Destructor is safe to call
*/
ArenaAllocator &operator=(ArenaAllocator &&other) noexcept;
Arena &operator=(Arena &&other) noexcept {
if (this != &other) {
while (current_block_) {
Block *prev = current_block_->prev;
std::free(current_block_);
current_block_ = prev;
}
initial_block_size_ = other.initial_block_size_;
current_block_ = other.current_block_;
other.current_block_ = nullptr;
}
return *this;
}
/**
* @brief Allocate raw memory with the specified size and alignment.
@@ -277,10 +312,14 @@ public:
* @brief Type-safe version of realloc_raw for arrays of type T.
*
* @param ptr Pointer to the existing allocation (must be from this allocator)
* If nullptr, behaves like allocate<T>(new_size)
* @param old_size Size of the existing allocation in number of T objects
* Ignored if ptr is nullptr
* @param new_size Desired new size in number of T objects
* @return Pointer to the reallocated memory (may be the same as ptr or
* different)
* @note Follows standard realloc() semantics: realloc(nullptr, size) ==
* malloc(size)
* @note Prints error to stderr and calls std::abort() if memory allocation
* fails or size overflow occurs
*/
@@ -288,7 +327,7 @@ public:
T *realloc(T *ptr, uint32_t old_size, uint32_t new_size) {
if (size_t(new_size) * sizeof(T) > std::numeric_limits<uint32_t>::max()) {
std::fprintf(stderr,
"ArenaAllocator: Reallocation size overflow for type %s "
"Arena: Reallocation size overflow for type %s "
"(new_size=%u, sizeof(T)=%zu)\n",
typeid(T).name(), new_size, sizeof(T));
std::abort();
@@ -297,38 +336,87 @@ public:
new_size * sizeof(T), alignof(T)));
}
/**
* @brief Smart pointer for arena-allocated objects with non-trivial
* destructors.
*
* Arena::Ptr calls the destructor but does not free memory (assumes
* arena allocation). This provides RAII semantics for objects that need
* cleanup without the overhead of individual memory deallocation.
*
* @tparam T The type of object being managed
*/
template <typename T> struct Ptr {
Ptr() noexcept : ptr_(nullptr) {}
explicit Ptr(T *ptr) noexcept : ptr_(ptr) {}
Ptr(const Ptr &) = delete;
Ptr &operator=(const Ptr &) = delete;
Ptr(Ptr &&other) noexcept : ptr_(other.ptr_) { other.ptr_ = nullptr; }
Ptr &operator=(Ptr &&other) noexcept {
if (this != &other) {
reset();
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
return *this;
}
~Ptr() { reset(); }
T *operator->() const noexcept { return ptr_; }
T &operator*() const noexcept { return *ptr_; }
T *get() const noexcept { return ptr_; }
explicit operator bool() const noexcept { return ptr_ != nullptr; }
T *release() noexcept {
T *result = ptr_;
ptr_ = nullptr;
return result;
}
void reset(T *new_ptr = nullptr) noexcept {
if (ptr_) {
ptr_->~T();
}
ptr_ = new_ptr;
}
private:
T *ptr_;
};
/**
* @brief Construct an object of type T in the arena using placement new.
*
* This is a convenience method that combines allocation with in-place
* construction. It properly handles alignment requirements for type T.
* This method returns different types based on whether T is trivially
* destructible:
* - For trivially destructible types: returns T* (raw pointer)
* - For non-trivially destructible types: returns Arena::Ptr<T>
* (smart pointer that calls destructor)
*
* @tparam T The type of object to construct (must be trivially destructible)
* @tparam T The type of object to construct
* @tparam Args Types of constructor arguments
* @param args Arguments to forward to T's constructor
* @return Pointer to the constructed object
* @return T* for trivially destructible types, Arena::Ptr<T>
* otherwise
* @note Prints error to stderr and calls std::abort() if memory allocation
* fails
*
* ## Type Requirements:
* T must be trivially destructible (std::is_trivially_destructible_v<T>).
* This prevents subtle bugs since destructors are never called for objects
* constructed in the arena.
*
*
* ## Note:
* Objects constructed this way cannot be individually destroyed.
* Their destructors will NOT be called automatically - hence the requirement
* for trivially destructible types.
*/
template <typename T, typename... Args> T *construct(Args &&...args) {
static_assert(
std::is_trivially_destructible_v<T>,
"ArenaAllocator::construct requires trivially destructible types. "
"Objects constructed in the arena will not have their destructors "
"called.");
template <typename T, typename... Args> auto construct(Args &&...args) {
void *ptr = allocate_raw(sizeof(T), alignof(T));
return new (ptr) T(std::forward<Args>(args)...);
T *obj = new (ptr) T(std::forward<Args>(args)...);
if constexpr (std::is_trivially_destructible_v<T>) {
return obj;
} else {
return Ptr<T>(obj);
}
}
/**
@@ -360,7 +448,7 @@ public:
template <typename T> T *allocate(uint32_t size) {
static_assert(
std::is_trivially_destructible_v<T>,
"ArenaAllocator::allocate requires trivially destructible types. "
"Arena::allocate requires trivially destructible types. "
"Objects allocated in the arena will not have their destructors "
"called.");
if (size == 0) {
@@ -368,7 +456,7 @@ public:
}
if (size_t(size) * sizeof(T) > std::numeric_limits<uint32_t>::max()) {
std::fprintf(stderr,
"ArenaAllocator: Allocation size overflow for type %s "
"Arena: Allocation size overflow for type %s "
"(size=%u, sizeof(T)=%zu)\n",
typeid(T).name(), size, sizeof(T));
std::abort();
@@ -377,6 +465,72 @@ public:
return static_cast<T *>(ptr);
}
/**
* @brief Allocate an array of type T and return it as a std::span<T>.
*
* This method provides bounds-safe allocation by returning a std::span
* that knows its size, improving safety over raw pointer allocation.
*
* @tparam T The type to allocate (must be trivially destructible)
* @param count The number of elements to allocate
* @return std::span<T> A span covering the allocated array
*
* ## Safety:
* The returned span is valid for the lifetime of the arena and until
* the next reset() call. The span provides bounds checking in debug
* builds and clear size information.
*
* ## Usage:
* ```cpp
* auto buffer = arena.allocate_span<char>(1024);
* auto strings = arena.allocate_span<std::string_view>(10);
* ```
*
* ## Note:
* Returns an empty span (nullptr, 0) if count is 0.
* This method only allocates memory - it does not construct objects.
*/
template <typename T> std::span<T> allocate_span(uint32_t count) {
if (count == 0) {
return std::span<T>{};
}
return std::span<T>{allocate<T>(count), count};
}
/**
* @brief Copy a string into arena memory and return a string_view.
*
* This method provides a safe way to copy string data into arena-allocated
* memory, ensuring the data remains valid for the arena's lifetime.
*
* @param str The string to copy into arena memory
* @return std::string_view pointing to the arena-allocated copy
*
* ## Safety:
* The returned string_view is valid for the lifetime of the arena and until
* the next reset() call. The string data is guaranteed to be null-terminated
* only if the input string was null-terminated.
*
* ## Usage:
* ```cpp
* Arena arena;
* std::string_view copy = arena.copy_string("Hello World");
* std::string_view copy2 = arena.copy_string(some_string_view);
* ```
*
* ## Note:
* Returns an empty string_view if the input string is empty.
* This method allocates exactly str.size() bytes (no null terminator added).
*/
std::string_view copy_string(std::string_view str) {
if (str.empty()) {
return std::string_view{};
}
char *copied = allocate<char>(str.size());
std::memcpy(copied, str.data(), str.size());
return std::string_view(copied, str.size());
}
/**
* @brief Reset the allocator to reuse the first block, freeing all others.
*
@@ -430,6 +584,40 @@ public:
return current_block_ ? current_block_->size - current_block_->offset : 0;
}
/**
* @brief Get all available space in the current block and claim it
* immediately.
*
* This method returns a pointer to all remaining space in the current block
* and immediately marks it as used in the arena. The caller should use
* realloc() to shrink the allocation to the actual amount needed.
*
* If no block exists or current block is full, creates a new block.
*
* @return Pointer to allocated space and the number of bytes allocated
* @note The caller must call realloc() to return unused space
* @note This is designed for speculative operations like printf formatting
* @note Postcondition: always returns at least 1 byte
*/
struct AllocatedSpace {
char *ptr;
size_t allocated_bytes;
};
AllocatedSpace allocate_remaining_space() {
if (!current_block_ || available_in_current_block() == 0) {
add_block(initial_block_size_);
}
char *allocated_ptr = current_block_->data() + current_block_->offset;
size_t available = available_in_current_block();
// Claim all remaining space
current_block_->offset = current_block_->size;
return {allocated_ptr, available};
}
/**
* @brief Get the total number of blocks in the allocator.
*
@@ -527,7 +715,7 @@ private:
};
/**
* @brief STL-compatible allocator that uses ArenaAllocator for memory
* @brief STL-compatible allocator that uses Arena for memory
* management.
* @tparam T The type of objects to allocate
*/
@@ -545,7 +733,7 @@ public:
using other = ArenaStlAllocator<U>;
};
explicit ArenaStlAllocator(ArenaAllocator *arena) noexcept : arena_(arena) {}
explicit ArenaStlAllocator(Arena *arena) noexcept : arena_(arena) {}
template <typename U>
ArenaStlAllocator(const ArenaStlAllocator<U> &other) noexcept
@@ -571,7 +759,62 @@ public:
return arena_ != other.arena_;
}
ArenaAllocator *arena_;
Arena *arena_;
template <typename U> friend class ArenaStlAllocator;
};
/// Simple arena-aware vector that doesn't have a destructor
/// Safe to return as span because both the vector and its data are
/// arena-allocated Uses arena's realloc() for efficient growth without copying
/// when possible
template <typename T> struct ArenaVector {
explicit ArenaVector(Arena *arena)
: arena_(arena), data_(nullptr), size_(0), capacity_(0) {}
void push_back(const T &item) {
if (size_ >= capacity_) {
grow();
}
data_[size_++] = item;
}
T *data() { return data_; }
const T *data() const { return data_; }
size_t size() const { return size_; }
bool empty() const { return size_ == 0; }
T &operator[](size_t index) { return data_[index]; }
const T &operator[](size_t index) const { return data_[index]; }
void clear() { size_ = 0; }
// Implicit conversion to std::span
operator std::span<T>() { return std::span<T>(data_, size_); }
operator std::span<const T>() const {
return std::span<const T>(data_, size_);
}
// Iterator support for range-based for loops
T *begin() { return data_; }
const T *begin() const { return data_; }
T *end() { return data_ + size_; }
const T *end() const { return data_ + size_; }
// No destructor - arena cleanup handles memory
private:
void grow() {
size_t new_capacity = capacity_ == 0 ? 8 : capacity_ * 2;
// arena.realloc() handles nullptr like standard realloc() - acts like
// malloc() This avoids copying when growing in-place is possible
data_ = arena_->realloc(data_, capacity_, new_capacity);
capacity_ = new_capacity;
}
Arena *arena_;
T *data_;
size_t size_;
size_t capacity_;
};
+387
View File
@@ -0,0 +1,387 @@
#include "commit_pipeline.hpp"
#include <cstring>
#include <pthread.h>
#include <unordered_set>
#include "commit_request.hpp"
#include "cpu_work.hpp"
#include "format.hpp"
#include "metric.hpp"
#include "pipeline_entry.hpp"
// Metric for banned request IDs memory usage
auto banned_request_ids_memory_gauge =
metric::create_gauge("weaseldb_banned_request_ids_memory_bytes",
"Memory used by banned request IDs arena")
.create({});
CommitPipeline::CommitPipeline(const weaseldb::Config &config)
: config_(config),
pipeline_(config.commit.pipeline_wait_strategy,
{1, 1, 1, config.commit.pipeline_release_threads}, lg_size) {
// Stage 0: Sequence assignment thread
sequence_thread_ = std::thread{[this]() {
pthread_setname_np(pthread_self(), "txn-sequence");
run_sequence_stage();
}};
// Stage 1: Precondition resolution thread
resolve_thread_ = std::thread{[this]() {
pthread_setname_np(pthread_self(), "txn-resolve");
run_resolve_stage();
}};
// Stage 2: Transaction persistence thread
persist_thread_ = std::thread{[this]() {
pthread_setname_np(pthread_self(), "txn-persist");
run_persist_stage();
}};
// Stage 3: Connection return to server threads (configurable count)
release_threads_.reserve(config.commit.pipeline_release_threads);
for (int i = 0; i < config.commit.pipeline_release_threads; ++i) {
release_threads_.emplace_back([this, i]() {
char name[16];
std::snprintf(name, sizeof(name), "txn-release-%d", i);
pthread_setname_np(pthread_self(), name);
run_release_stage(i);
});
}
}
CommitPipeline::~CommitPipeline() {
// Send shutdown signals for all release threads (adjacent in same batch)
{
int num_release_threads = static_cast<int>(release_threads_.size());
auto guard = pipeline_.push(num_release_threads, true);
for (int i = 0; i < num_release_threads; ++i) {
guard.batch[i] = ShutdownEntry{};
}
}
// Join all pipeline threads
sequence_thread_.join();
resolve_thread_.join();
persist_thread_.join();
for (auto &thread : release_threads_) {
thread.join();
}
}
void CommitPipeline::submit_batch(std::span<PipelineEntry> entries) {
if (entries.empty()) {
return;
}
// Get pipeline guard for batch size
auto guard = pipeline_.push(entries.size(), /*block=*/true);
// Move entries into pipeline slots
std::move(entries.begin(), entries.end(), guard.batch.begin());
// Guard destructor publishes batch to stage 0
}
// AVOID BLOCKING IN THIS STAGE!
void CommitPipeline::run_sequence_stage() {
int64_t next_version = 1;
// Request ID deduplication (sequence stage only)
Arena banned_request_arena;
using BannedRequestIdSet =
std::unordered_set<std::string_view, std::hash<std::string_view>,
std::equal_to<std::string_view>,
ArenaStlAllocator<std::string_view>>;
BannedRequestIdSet banned_request_ids{
ArenaStlAllocator<std::string_view>(&banned_request_arena)};
int expected_shutdowns = config_.commit.pipeline_release_threads;
for (int shutdowns_received = 0; shutdowns_received < expected_shutdowns;) {
auto guard = pipeline_.acquire(0, 0);
auto &batch = guard.batch;
// Stage 0: Sequence assignment
// This stage performs ONLY work that requires serial processing:
// - Version/sequence number assignment (must be sequential)
// - Request ID banned list management
for (auto &entry : batch) {
// Pattern match on pipeline entry variant
std::visit(
[&](auto &&e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, ShutdownEntry>) {
++shutdowns_received;
} else if constexpr (std::is_same_v<T, CommitEntry>) {
// Process commit entry: check banned list, assign version
auto &commit_entry = e;
assert(commit_entry.commit_request);
// Check if request_id is banned (for status queries)
// Only check CommitRequest request_id, not HTTP header
if (commit_entry.commit_request &&
commit_entry.commit_request->request_id().has_value()) {
auto commit_request_id =
commit_entry.commit_request->request_id().value();
if (banned_request_ids.contains(commit_request_id)) {
// Request ID is banned, this commit should fail
commit_entry.response_json =
R"({"status": "not_committed", "error": "request_id_banned"})";
return;
}
}
// Assign sequential version number
commit_entry.assigned_version = next_version++;
} else if constexpr (std::is_same_v<T, StatusEntry>) {
// Process status entry: add request_id to banned list, get
// version upper bound
auto &status_entry = e;
// Add request_id to banned list - store the string in arena and
// use string_view
std::string_view request_id_view =
banned_request_arena.copy_string(
status_entry.status_request_id);
banned_request_ids.insert(request_id_view);
// Update memory usage metric
banned_request_ids_memory_gauge.set(
banned_request_arena.total_allocated());
// Set version upper bound to current highest assigned version
status_entry.version_upper_bound = next_version - 1;
} else if constexpr (std::is_same_v<T, HealthCheckEntry>) {
// Process health check entry: noop in sequence stage
}
},
entry);
}
}
}
// AVOID BLOCKING IN THIS STAGE!
void CommitPipeline::run_resolve_stage() {
int expected_shutdowns = config_.commit.pipeline_release_threads;
for (int shutdowns_received = 0; shutdowns_received < expected_shutdowns;) {
auto guard = pipeline_.acquire(1, 0, /*maxBatch*/ 1);
auto &batch = guard.batch;
// Stage 1: Precondition resolution
// This stage must be serialized to maintain consistent database state view
// - Validate preconditions against current database state
// - Check for conflicts with other transactions
for (auto &entry : batch) {
// Pattern match on pipeline entry variant
std::visit(
[&](auto &&e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, ShutdownEntry>) {
++shutdowns_received;
} else if constexpr (std::is_same_v<T, CommitEntry>) {
// Process commit entry: accept all commits (simplified
// implementation)
auto &commit_entry = e;
// Accept all commits (simplified implementation)
commit_entry.resolve_success = true;
} else if constexpr (std::is_same_v<T, StatusEntry>) {
// Status entries are not processed in resolve stage
// They were already handled in sequence stage
} else if constexpr (std::is_same_v<T, HealthCheckEntry>) {
// Perform configurable CPU-intensive work for benchmarking
spend_cpu_cycles(config_.benchmark.ok_resolve_iterations);
}
},
entry);
}
}
}
void CommitPipeline::run_persist_stage() {
int expected_shutdowns = config_.commit.pipeline_release_threads;
for (int shutdowns_received = 0; shutdowns_received < expected_shutdowns;) {
auto guard = pipeline_.acquire(2, 0);
auto &batch = guard.batch;
// Stage 2: Transaction persistence
// Mark everything as durable immediately (simplified implementation)
// In real implementation: batch S3 writes, update subscribers, etc.
for (auto &entry : batch) {
// Pattern match on pipeline entry variant
std::visit(
[&](auto &&e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, ShutdownEntry>) {
++shutdowns_received;
} else if constexpr (std::is_same_v<T, CommitEntry>) {
// Process commit entry: mark as durable, generate response
auto &commit_entry = e;
// Check if connection is still alive first
// Skip if resolve failed or connection is in error state
if (!commit_entry.commit_request ||
!commit_entry.resolve_success) {
return;
}
// Mark as persisted and update committed version high water mark
commit_entry.persist_success = true;
committed_version_.store(commit_entry.assigned_version,
std::memory_order_seq_cst);
const CommitRequest &commit_request =
*commit_entry.commit_request;
// Generate success JSON response with actual assigned version
std::string_view response_json;
if (commit_request.request_id().has_value()) {
response_json = format(
commit_entry.request_arena,
R"({"request_id":"%.*s","status":"committed","version":%ld,"leader_id":"leader123"})",
static_cast<int>(
commit_request.request_id().value().size()),
commit_request.request_id().value().data(),
commit_entry.assigned_version);
} else {
response_json = format(
commit_entry.request_arena,
R"({"status":"committed","version":%ld,"leader_id":"leader123"})",
commit_entry.assigned_version);
}
// Store JSON response in arena for release stage
char *json_buffer =
commit_entry.request_arena.template allocate<char>(
response_json.size());
std::memcpy(json_buffer, response_json.data(),
response_json.size());
commit_entry.response_json =
std::string_view(json_buffer, response_json.size());
return; // Continue processing
} else if constexpr (std::is_same_v<T, StatusEntry>) {
// Process status entry: generate not_committed response
auto &status_entry = e;
// Store JSON response for release stage
status_entry.response_json = R"({"status": "not_committed"})";
} else if constexpr (std::is_same_v<T, HealthCheckEntry>) {
// Process health check entry: generate OK response
auto &health_check_entry = e;
// Store plain text "OK" response for release stage
health_check_entry.response_json = "OK";
} else if constexpr (std::is_same_v<T, GetVersionEntry>) {
auto &get_version_entry = e;
// TODO validate we're still the leader at some version > the
// proposed version for external consistency.
// TODO include leader in response
get_version_entry.response_json = format(
get_version_entry.request_arena,
R"({"version":%ld,"leader":""})", get_version_entry.version);
}
},
entry);
}
}
}
void CommitPipeline::run_release_stage(int thread_index) {
for (int shutdowns_received = 0; shutdowns_received < 1;) {
auto guard = pipeline_.acquire(3, thread_index);
auto &batch = guard.batch;
// Stage 3: Connection release
// Return connections to server for response transmission
for (auto it = batch.begin(); it != batch.end(); ++it) {
auto &entry = *it;
// Partition work: thread 0 handles even indices, thread 1 handles odd
// indices
if (static_cast<int>(it.index() %
config_.commit.pipeline_release_threads) !=
thread_index) {
continue;
}
// Process non-shutdown entries with partitioning
std::visit(
[&](auto &&e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, ShutdownEntry>) {
// Already handled above
++shutdowns_received;
} else if constexpr (std::is_same_v<T, CommitEntry>) {
// Process commit entry: return connection to server
auto &commit_entry = e;
auto conn_ref = commit_entry.connection.lock();
if (!conn_ref) {
// Connection is gone, drop the entry silently
return; // Skip this entry and continue processing
}
// Send the JSON response using protocol-agnostic interface
// HTTP formatting will happen in on_preprocess_writes()
conn_ref->send_response(commit_entry.handle,
commit_entry.response_json,
std::move(commit_entry.request_arena));
} else if constexpr (std::is_same_v<T, StatusEntry>) {
// Process status entry: return connection to server
auto &status_entry = e;
auto conn_ref = status_entry.connection.lock();
if (!conn_ref) {
// Connection is gone, drop the entry silently
return; // Skip this entry and continue processing
}
// Send the JSON response using protocol-agnostic interface
// HTTP formatting will happen in on_preprocess_writes()
conn_ref->send_response(status_entry.handle,
status_entry.response_json,
std::move(status_entry.request_arena));
} else if constexpr (std::is_same_v<T, HealthCheckEntry>) {
// Process health check entry: return connection to server
auto &health_check_entry = e;
auto conn_ref = health_check_entry.connection.lock();
if (!conn_ref) {
// Connection is gone, drop the entry silently
return; // Skip this entry and continue processing
}
// Send the response using protocol-agnostic interface
// HTTP formatting will happen in on_preprocess_writes()
conn_ref->send_response(
health_check_entry.handle, health_check_entry.response_json,
std::move(health_check_entry.request_arena));
} else if constexpr (std::is_same_v<T, GetVersionEntry>) {
auto &get_version_entry = e;
auto conn_ref = get_version_entry.connection.lock();
if (!conn_ref) {
// Connection is gone, drop the entry silently
return; // Skip this entry and continue processing
}
// Send the response using protocol-agnostic interface
// HTTP formatting will happen in on_preprocess_writes()
conn_ref->send_response(
get_version_entry.handle, get_version_entry.response_json,
std::move(get_version_entry.request_arena));
}
},
entry);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
#pragma once
#include <atomic>
#include <span>
#include <thread>
#include "config.hpp"
#include "pipeline_entry.hpp"
#include "thread_pipeline.hpp"
/**
* High-performance 4-stage commit processing pipeline.
*
* Provides protocol-agnostic transaction processing through a lock-free
* multi-stage pipeline optimized for high throughput and low latency.
*
* Pipeline Stages:
* 1. Sequence: Version assignment and request ID deduplication
* 2. Resolve: Precondition validation and conflict detection
* 3. Persist: Transaction durability and response generation
* 4. Release: Connection return and response transmission
*
* Thread Safety:
* - submit_batch() is thread-safe for concurrent producers
* - Internal pipeline uses lock-free algorithms
* - Each stage runs on dedicated threads for optimal performance
*
* Usage:
* ```cpp
* CommitPipeline pipeline(config);
*
* // Build pipeline entries
* std::vector<PipelineEntry> entries;
* entries.emplace_back(CommitEntry(connection, context, request, arena));
*
* // Submit for processing
* pipeline.submit_batch(entries);
* ```
*/
struct CommitPipeline {
/**
* Create pipeline with 4 processing stages.
*
* @param config Server configuration for pipeline tuning
*/
explicit CommitPipeline(const weaseldb::Config &config);
/**
* Destructor ensures clean shutdown and thread join.
* Sends shutdown signal through pipeline and waits for all stages to
* complete.
*/
~CommitPipeline();
/**
* Submit batch of pipeline entries for processing.
*
* Thread-safe method for submitting work to the pipeline. Entries flow
* through all 4 stages in order with proper synchronization.
*
* @param entries Span of pipeline entries to process
*
* Entry types:
* - CommitEntry: Full transaction processing through all stages
* - StatusEntry: Request status lookup with sequence stage processing
* - HealthCheckEntry: Health check with configurable CPU work
* - ShutdownEntry: Coordinated pipeline shutdown signal
*
* @note Thread Safety: Safe for concurrent calls from multiple threads
* @note Performance: Batching reduces pipeline contention - prefer larger
* batches
* @note Blocking: May block if pipeline is at capacity (backpressure)
*/
void submit_batch(std::span<PipelineEntry> entries);
/**
* Get the highest committed version number.
*
* @return Current committed version (persist thread writes, other threads
* read)
* @note Thread Safety: Safe to read from any thread
*/
int64_t get_committed_version() const {
return committed_version_.load(std::memory_order_seq_cst);
}
private:
// Configuration reference
const weaseldb::Config &config_;
// Pipeline state (persist thread writes, other threads read)
std::atomic<int64_t> committed_version_{0}; // Highest committed version
// Lock-free pipeline configuration
static constexpr int lg_size = 16; // Ring buffer size (2^16 slots)
// 4-stage pipeline: sequence -> resolve -> persist -> release
ThreadPipeline<PipelineEntry> pipeline_;
// Stage processing threads
std::thread sequence_thread_;
std::thread resolve_thread_;
std::thread persist_thread_;
std::vector<std::thread> release_threads_;
// Pipeline stage main loops
void run_sequence_stage();
void run_resolve_stage();
void run_persist_stage();
void run_release_stage(int thread_index);
// Pipeline batch type alias
using BatchType = ThreadPipeline<PipelineEntry>::Batch;
// Make non-copyable and non-movable
CommitPipeline(const CommitPipeline &) = delete;
CommitPipeline &operator=(const CommitPipeline &) = delete;
CommitPipeline(CommitPipeline &&) = delete;
CommitPipeline &operator=(CommitPipeline &&) = delete;
};
+5 -12
View File
@@ -5,7 +5,7 @@
#include <string_view>
#include <vector>
#include "arena_allocator.hpp"
#include "arena.hpp"
/**
* @brief Represents a precondition for optimistic concurrency control.
@@ -63,7 +63,7 @@ struct Operation {
*/
struct CommitRequest {
private:
ArenaAllocator arena_;
Arena arena_;
std::optional<std::string_view> request_id_;
std::string_view leader_id_;
int64_t read_version_ = 0;
@@ -155,7 +155,7 @@ public:
*
* @return Reference to the arena allocator
*/
const ArenaAllocator &arena() const { return arena_; }
const Arena &arena() const { return arena_; }
/**
* @brief Get access to the underlying arena allocator for allocation.
@@ -166,7 +166,7 @@ public:
*
* @return Reference to the arena allocator
*/
ArenaAllocator &arena() { return arena_; }
Arena &arena() { return arena_; }
/**
* @brief Reset the commit request for reuse.
@@ -244,14 +244,7 @@ public:
* @return String view pointing to arena-allocated memory
*/
std::string_view copy_to_arena(std::string_view str) {
if (str.empty()) {
return {};
}
char *arena_str = arena_.allocate<char>(str.size());
std::memcpy(arena_str, str.data(), str.size());
return std::string_view(arena_str, str.size());
return arena_.copy_string(str);
}
/**
+91 -38
View File
@@ -1,5 +1,4 @@
#include "config.hpp"
#include <fstream>
#include <iostream>
#include <toml.hpp>
@@ -14,6 +13,7 @@ ConfigParser::load_from_file(const std::string &file_path) {
parse_server_config(toml_data, config.server);
parse_commit_config(toml_data, config.commit);
parse_subscription_config(toml_data, config.subscription);
parse_benchmark_config(toml_data, config.benchmark);
if (!validate_config(config)) {
return std::nullopt;
@@ -36,6 +36,7 @@ ConfigParser::parse_toml_string(const std::string &toml_content) {
parse_server_config(toml_data, config.server);
parse_commit_config(toml_data, config.commit);
parse_subscription_config(toml_data, config.subscription);
parse_benchmark_config(toml_data, config.benchmark);
if (!validate_config(config)) {
return std::nullopt;
@@ -79,28 +80,41 @@ void ConfigParser::parse_section(const auto &toml_data,
void ConfigParser::parse_server_config(const auto &toml_data,
ServerConfig &config) {
parse_section(toml_data, "server", [&](const auto &srv) {
parse_field(srv, "bind_address", config.bind_address);
parse_field(srv, "port", config.port);
parse_field(srv, "unix_socket_path", config.unix_socket_path);
// Parse interfaces array
if (srv.contains("interfaces")) {
auto interfaces = srv.at("interfaces");
if (interfaces.is_array()) {
for (const auto &iface : interfaces.as_array()) {
if (iface.contains("type")) {
std::string type = iface.at("type").as_string();
if (type == "tcp") {
std::string address = iface.at("address").as_string();
int port = iface.at("port").as_integer();
config.interfaces.push_back(ListenInterface::tcp(address, port));
} else if (type == "unix") {
std::string path = iface.at("path").as_string();
config.interfaces.push_back(ListenInterface::unix_socket(path));
}
}
}
}
}
// If no interfaces configured, use default TCP interface
if (config.interfaces.empty()) {
config.interfaces.push_back(ListenInterface::tcp("127.0.0.1", 8080));
}
parse_field(srv, "max_request_size_bytes", config.max_request_size_bytes);
parse_field(srv, "io_threads", config.io_threads);
// Set epoll_instances default to io_threads if not explicitly configured
bool epoll_instances_specified = srv.contains("epoll_instances");
if (!epoll_instances_specified) {
config.epoll_instances = config.io_threads;
} else {
parse_field(srv, "epoll_instances", config.epoll_instances);
}
// epoll_instances removed - now 1:1 with io_threads
parse_field(srv, "event_batch_size", config.event_batch_size);
parse_field(srv, "max_connections", config.max_connections);
parse_field(srv, "read_buffer_size", config.read_buffer_size);
// Clamp epoll_instances to not exceed io_threads
if (config.epoll_instances > config.io_threads) {
config.epoll_instances = config.io_threads;
}
// epoll_instances validation removed - now always equals io_threads
});
}
@@ -112,6 +126,25 @@ void ConfigParser::parse_commit_config(const auto &toml_data,
config.request_id_retention_hours);
parse_field(commit, "request_id_retention_versions",
config.request_id_retention_versions);
// Parse wait strategy
if (commit.contains("pipeline_wait_strategy")) {
std::string strategy_str =
toml::get<std::string>(commit.at("pipeline_wait_strategy"));
if (strategy_str == "WaitIfStageEmpty") {
config.pipeline_wait_strategy = WaitStrategy::WaitIfStageEmpty;
} else if (strategy_str == "WaitIfUpstreamIdle") {
config.pipeline_wait_strategy = WaitStrategy::WaitIfUpstreamIdle;
} else if (strategy_str == "Never") {
config.pipeline_wait_strategy = WaitStrategy::Never;
} else {
std::cerr << "Warning: Unknown pipeline_wait_strategy '" << strategy_str
<< "', using default (WaitIfUpstreamIdle)" << std::endl;
}
}
parse_field(commit, "pipeline_release_threads",
config.pipeline_release_threads);
});
}
@@ -124,28 +157,48 @@ void ConfigParser::parse_subscription_config(const auto &toml_data,
});
}
void ConfigParser::parse_benchmark_config(const auto &toml_data,
BenchmarkConfig &config) {
parse_section(toml_data, "benchmark", [&](const auto &bench) {
parse_field(bench, "ok_resolve_iterations", config.ok_resolve_iterations);
});
}
bool ConfigParser::validate_config(const Config &config) {
bool valid = true;
// Validate server configuration
if (config.server.unix_socket_path.empty()) {
// TCP mode validation
if (config.server.port <= 0 || config.server.port > 65535) {
std::cerr << "Configuration error: server.port must be between 1 and "
"65535, got "
<< config.server.port << std::endl;
// Validate server interfaces
if (config.server.interfaces.empty()) {
std::cerr << "Configuration error: no interfaces configured" << std::endl;
valid = false;
}
} else {
// Unix socket mode validation
if (config.server.unix_socket_path.length() >
107) { // UNIX_PATH_MAX is typically 108
std::cerr << "Configuration error: unix_socket_path too long (max 107 "
"chars), got "
<< config.server.unix_socket_path.length() << " chars"
for (const auto &iface : config.server.interfaces) {
if (iface.type == ListenInterface::Type::TCP) {
if (iface.port <= 0 || iface.port > 65535) {
std::cerr << "Configuration error: TCP port must be between 1 and "
"65535, got "
<< iface.port << std::endl;
valid = false;
}
if (iface.address.empty()) {
std::cerr << "Configuration error: TCP address cannot be empty"
<< std::endl;
valid = false;
}
} else { // Unix socket
if (iface.path.empty()) {
std::cerr << "Configuration error: Unix socket path cannot be empty"
<< std::endl;
valid = false;
}
if (iface.path.length() > 107) { // UNIX_PATH_MAX is typically 108
std::cerr << "Configuration error: Unix socket path too long (max 107 "
"chars), got "
<< iface.path.length() << " chars" << std::endl;
valid = false;
}
}
}
if (config.server.max_request_size_bytes == 0) {
@@ -169,15 +222,7 @@ bool ConfigParser::validate_config(const Config &config) {
valid = false;
}
if (config.server.epoll_instances < 1 ||
config.server.epoll_instances > config.server.io_threads) {
std::cerr
<< "Configuration error: server.epoll_instances must be between 1 "
"and io_threads ("
<< config.server.io_threads << "), got "
<< config.server.epoll_instances << std::endl;
valid = false;
}
// epoll_instances validation removed - now always 1:1 with io_threads
if (config.server.event_batch_size < 1 ||
config.server.event_batch_size > 10000) {
@@ -227,6 +272,14 @@ bool ConfigParser::validate_config(const Config &config) {
valid = false;
}
if (config.commit.pipeline_release_threads < 1 ||
config.commit.pipeline_release_threads > 64) {
std::cerr << "Configuration error: commit.pipeline_release_threads must be "
"between 1 and 64, got "
<< config.commit.pipeline_release_threads << std::endl;
valid = false;
}
// Validate subscription configuration
if (config.subscription.max_buffer_size_bytes == 0) {
std::cerr << "Configuration error: subscription.max_buffer_size_bytes must "
+52 -9
View File
@@ -3,26 +3,47 @@
#include <chrono>
#include <optional>
#include <string>
#include <vector>
#include "thread_pipeline.hpp"
namespace weaseldb {
/**
* @brief Configuration for a single network interface to listen on.
*/
struct ListenInterface {
enum class Type { TCP, Unix };
Type type;
/// For TCP: IP address to bind to (e.g., "127.0.0.1", "0.0.0.0")
std::string address;
/// For TCP: port number
int port = 0;
/// For Unix: socket file path
std::string path;
// Factory methods for cleaner config creation
static ListenInterface tcp(const std::string &addr, int port_num) {
return {Type::TCP, addr, port_num, ""};
}
static ListenInterface unix_socket(const std::string &socket_path) {
return {Type::Unix, "", 0, socket_path};
}
};
/**
* @brief Configuration settings for the WeaselDB server component.
*/
struct ServerConfig {
/// IP address to bind the server to (default: localhost)
std::string bind_address = "127.0.0.1";
/// TCP port number for the server to listen on
int port = 8080;
/// Unix socket path (if specified, takes precedence over TCP)
std::string unix_socket_path;
/// Network interfaces to listen on (TCP and/or Unix sockets)
std::vector<ListenInterface> interfaces;
/// Maximum size in bytes for incoming HTTP requests (default: 1MB)
int64_t max_request_size_bytes = 1024 * 1024;
/// Number of I/O threads for handling connections and network events
/// Each I/O thread gets its own dedicated epoll instance
int io_threads = 1;
/// Number of epoll instances to reduce epoll_ctl contention (default:
/// io_threads, max: io_threads)
int epoll_instances = 1;
/// Event batch size for epoll processing
int event_batch_size = 32;
/// Maximum number of concurrent connections (0 = unlimited)
@@ -41,6 +62,16 @@ struct CommitConfig {
std::chrono::hours request_id_retention_hours{24};
/// Minimum number of commit versions to retain request IDs for
int64_t request_id_retention_versions = 100000000;
/// Wait strategy for the commit pipeline
/// - WaitIfStageEmpty: Block when individual stages are empty (default, safe
/// for shared CPUs)
/// - WaitIfUpstreamIdle: Block only when all upstream stages are idle
/// (requires dedicated cores)
/// - Never: Never block, busy-wait continuously (requires dedicated cores)
WaitStrategy pipeline_wait_strategy = WaitStrategy::WaitIfUpstreamIdle;
/// Number of threads in the release stage (final stage of commit pipeline)
/// Default: 1 thread for simplicity (can increase for higher throughput)
int pipeline_release_threads = 1;
};
/**
@@ -53,6 +84,15 @@ struct SubscriptionConfig {
std::chrono::seconds keepalive_interval{30};
};
/**
* @brief Configuration settings for benchmarking and health check behavior.
*/
struct BenchmarkConfig {
/// CPU-intensive loop iterations for /ok requests in resolve stage
/// 0 = health check only, 4000 = default benchmark load (740ns, 1M req/s)
int ok_resolve_iterations = 0;
};
/**
* @brief Top-level configuration container for all WeaselDB settings.
*/
@@ -60,6 +100,7 @@ struct Config {
ServerConfig server; ///< Server networking and request handling settings
CommitConfig commit; ///< Commit processing and validation settings
SubscriptionConfig subscription; ///< Subscription streaming settings
BenchmarkConfig benchmark; ///< Benchmarking and health check settings
};
/**
@@ -131,6 +172,8 @@ private:
static void parse_commit_config(const auto &toml_data, CommitConfig &config);
static void parse_subscription_config(const auto &toml_data,
SubscriptionConfig &config);
static void parse_benchmark_config(const auto &toml_data,
BenchmarkConfig &config);
};
} // namespace weaseldb
+246 -52
View File
@@ -1,52 +1,159 @@
#include "connection.hpp"
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <sys/epoll.h>
#include "server.hpp" // Need this for release_back_to_server implementation
#include "metric.hpp"
#include "server.hpp" // Need this for server reference
namespace {
// Thread-local metric instances
thread_local auto connections_total =
metric::create_counter("weaseldb_connections_total",
"Total number of connections accepted")
.create({});
thread_local auto connections_active =
metric::create_gauge("weaseldb_connections_active",
"Number of currently active connections")
.create({});
thread_local auto bytes_read =
metric::create_counter("weaseldb_bytes_read_total",
"Total number of bytes read from clients")
.create({});
thread_local auto bytes_written =
metric::create_counter("weaseldb_bytes_written_total",
"Total number of bytes written to clients")
.create({});
thread_local auto write_eagain_failures =
metric::create_counter(
"weaseldb_write_eagain_failures_total",
"Total number of write operations that failed with EAGAIN")
.create({});
} // namespace
// Static thread-local storage for iovec buffer
static thread_local std::vector<struct iovec> g_iovec_buffer{IOV_MAX};
// Thread-local storage for arenas to be freed after unlocking
static thread_local std::vector<Arena> g_arenas_to_free;
Connection::Connection(struct sockaddr_storage addr, int fd, int64_t id,
size_t epoll_index, ConnectionHandler *handler,
Server &server)
: fd_(fd), id_(id), epoll_index_(epoll_index), addr_(addr), arena_(),
handler_(handler), server_(server.weak_from_this()) {
server.active_connections_.fetch_add(1, std::memory_order_relaxed);
WeakRef<Server> server)
: id_(id), epoll_index_(epoll_index), addr_(addr), handler_(handler),
server_(std::move(server)), fd_(fd) {
auto server_ref = server_.lock();
// Should only be called from the io thread
assert(server_ref);
server_ref->active_connections_.fetch_add(1, std::memory_order_relaxed);
// Increment connection metrics using thread-local instances
connections_total.inc();
connections_active.inc();
assert(handler_);
handler_->on_connection_established(*this);
}
Connection::~Connection() {
if (handler_) {
handler_->on_connection_closed(*this);
}
if (auto server_ptr = server_.lock()) {
server_ptr->active_connections_.fetch_sub(1, std::memory_order_relaxed);
}
int e = close(fd_);
if (fd_ >= 0) {
int e = ::close(fd_);
if (e == -1 && errno != EINTR) {
perror("close");
std::abort();
}
// EINTR ignored - fd is guaranteed closed on Linux
}
void Connection::append_message(std::string_view s, bool copy_to_arena) {
if (copy_to_arena) {
char *arena_str = arena_.allocate<char>(s.size());
std::memcpy(arena_str, s.data(), s.size());
messages_.emplace_back(arena_str, s.size());
} else {
messages_.push_back(s);
}
outgoing_bytes_queued_ += s.size();
}
int Connection::readBytes(char *buf, size_t buffer_size) {
void Connection::close() {
std::lock_guard lock{mutex_};
auto server_ptr = server_.lock();
// Should only be called from the io thread
assert(server_ptr);
server_ptr->active_connections_.fetch_sub(1, std::memory_order_relaxed);
assert(fd_ >= 0);
int e = ::close(fd_);
if (e == -1 && errno != EINTR) {
perror("close");
std::abort();
}
// EINTR ignored - fd is guaranteed closed on Linux
fd_ = -1;
// Decrement active connections gauge
connections_active.dec();
}
// Called from I/O thread only
void Connection::append_bytes(std::span<std::string_view> data_parts,
Arena arena, ConnectionShutdown shutdown_mode) {
// Prevent queueing messages after shutdown has been requested
if (shutdown_requested_ != ConnectionShutdown::None) {
return;
}
// Check if queue was empty to determine if we need to enable EPOLLOUT
bool was_empty = message_queue_.empty();
// Set shutdown mode if requested
if (shutdown_mode != ConnectionShutdown::None) {
shutdown_requested_ = shutdown_mode;
}
// Add message to queue
// TODO this allocates while holding the connection lock
message_queue_.emplace_back(Message{std::move(arena), data_parts});
// If queue was empty, we need to add EPOLLOUT interest.
if (was_empty) {
auto server = server_.lock();
if (fd_ >= 0 && server) {
// Add EPOLLOUT interest - pipeline thread manages epoll
struct epoll_event event;
event.data.fd = fd_;
event.events = EPOLLIN | EPOLLOUT;
tsan_release();
// I think we have to call epoll_ctl while holding mutex_. Otherwise a
// call that clears the write interest could get reordered with one that
// sets it and we would hang.
epoll_ctl(server->event_loops_[epoll_index_].epoll_fd_, EPOLL_CTL_MOD,
fd_, &event);
}
}
}
// May be called from a foreign thread!
void Connection::send_response(ProtocolHandle handle,
std::string_view response_json, Arena arena) {
std::unique_lock lock(mutex_);
// Prevent queueing responses after shutdown has been requested
if (shutdown_requested_ != ConnectionShutdown::None) {
return;
}
// Store response in queue for protocol handler processing
pending_response_queue_.emplace_back(
PendingResponse{handle, response_json, std::move(arena)});
// Trigger epoll interest if this is the first pending response
if (pending_response_queue_.size() == 1) {
auto server = server_.lock();
if (fd_ >= 0 && server) {
// Add EPOLLOUT interest to trigger on_preprocess_writes
struct epoll_event event;
event.data.fd = fd_;
event.events = EPOLLIN | EPOLLOUT;
tsan_release();
epoll_ctl(server->event_loops_[epoll_index_].epoll_fd_, EPOLL_CTL_MOD,
fd_, &event);
}
}
}
int Connection::read_bytes(char *buf, size_t buffer_size) {
int r;
for (;;) {
r = read(fd_, buf, buffer_size);
@@ -63,65 +170,152 @@ int Connection::readBytes(char *buf, size_t buffer_size) {
if (r == 0) {
return -1;
}
// Increment bytes read metric
assert(r > 0);
bytes_read.inc(r);
return r;
}
}
bool Connection::writeBytes() {
while (!messages_.empty()) {
uint32_t Connection::write_bytes() {
ssize_t total_bytes_written = 0;
uint32_t result = 0;
while (true) {
// Build iovec array while holding mutex using thread-local buffer
int iov_count = 0;
{
std::lock_guard lock(mutex_);
if (message_queue_.empty()) {
break;
}
// Build iovec array up to IOV_MAX limit using thread-local vector
assert(g_iovec_buffer.size() == IOV_MAX);
struct iovec *iov = g_iovec_buffer.data();
int iov_count = 0;
for (auto it = messages_.begin();
it != messages_.end() && iov_count < IOV_MAX; ++it) {
const auto &msg = *it;
for (auto &message : message_queue_) {
if (iov_count >= IOV_MAX)
break;
for (const auto &part : message.data_parts) {
if (iov_count >= IOV_MAX)
break;
if (part.empty())
continue;
iov[iov_count] = {
const_cast<void *>(static_cast<const void *>(msg.data())),
msg.size()};
const_cast<void *>(static_cast<const void *>(part.data())),
part.size()};
iov_count++;
}
}
assert(iov_count > 0);
if (iov_count == 0)
break;
} // Release mutex during I/O
// Perform I/O without holding mutex
ssize_t w;
for (;;) {
w = writev(fd_, iov, iov_count);
struct msghdr msg = {};
msg.msg_iov = g_iovec_buffer.data();
msg.msg_iovlen = iov_count;
w = sendmsg(fd_, &msg, MSG_NOSIGNAL);
if (w == -1) {
if (errno == EINTR) {
continue; // Standard practice: retry on signal interruption
}
if (errno == EAGAIN) {
return false;
// Increment EAGAIN failure metric
write_eagain_failures.inc();
bytes_written.inc(total_bytes_written);
return result;
}
perror("writev");
return true;
perror("sendmsg");
result |= Error;
return result;
}
break;
}
result |= Progress;
assert(w > 0);
total_bytes_written += w;
// Handle partial writes by updating string_view data/size
size_t bytes_written = static_cast<size_t>(w);
outgoing_bytes_queued_ -= bytes_written;
while (bytes_written > 0 && !messages_.empty()) {
auto &front = messages_.front();
// Handle partial writes by updating message data_parts
{
std::lock_guard lock(mutex_);
size_t bytes_remaining = static_cast<size_t>(w);
if (bytes_written >= front.size()) {
// This message is completely written
bytes_written -= front.size();
messages_.pop_front();
while (bytes_remaining > 0 && !message_queue_.empty()) {
auto &front_message = message_queue_.front();
for (auto &part : front_message.data_parts) {
if (part.empty())
continue;
if (bytes_remaining >= part.size()) {
// This part is completely written
bytes_remaining -= part.size();
part = std::string_view(); // Mark as consumed
} else {
// Partial write of this message - update string_view
front = std::string_view(front.data() + bytes_written,
front.size() - bytes_written);
bytes_written = 0;
// Partial write of this part
part = std::string_view(part.data() + bytes_remaining,
part.size() - bytes_remaining);
bytes_remaining = 0;
break;
}
}
}
assert(messages_.empty());
return false;
// Move arena to thread-local vector for deferred cleanup
g_arenas_to_free.emplace_back(std::move(front_message.arena));
message_queue_.pop_front();
if (result & Close) {
break;
}
}
}
}
// Check if queue is empty and remove EPOLLOUT interest
{
std::lock_guard lock(mutex_);
if (message_queue_.empty() && pending_response_queue_.empty()) {
auto server = server_.lock();
if (server) {
struct epoll_event event;
event.data.fd = fd_;
event.events = EPOLLIN; // Remove EPOLLOUT
tsan_release();
// I think we have to call epoll_ctl while holding mutex_. Otherwise a
// call that clears the write interest could get reordered with one that
// sets it and we would hang.
epoll_ctl(server->event_loops_[epoll_index_].epoll_fd_, EPOLL_CTL_MOD,
fd_, &event);
}
// Handle shutdown modes after all messages are sent
if (shutdown_requested_ == ConnectionShutdown::WriteOnly) {
// Shutdown write side but keep connection alive for reading
shutdown(fd_, SHUT_WR);
} else if (shutdown_requested_ == ConnectionShutdown::Full) {
result |= Close;
}
}
}
// Increment bytes written metric
bytes_written.inc(total_bytes_written);
// Clean up arenas after all mutex operations are complete
// This avoids holding the connection mutex while calling free()
g_arenas_to_free.clear();
return result;
}
+173 -217
View File
@@ -1,48 +1,92 @@
#pragma once
#include <atomic>
#include <cassert>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <span>
#include <sys/socket.h>
#include <sys/uio.h>
#include <unistd.h>
#include "arena_allocator.hpp"
#include "arena.hpp"
#include "connection_handler.hpp"
#include "reference.hpp"
#ifndef __has_feature
#define __has_feature(x) 0
#endif
/**
* Represents a single client connection with efficient memory management.
*
* Connection ownership model:
* - Created by I/O thread, processed immediately, then transferred to epoll via
* raw pointer
* - I/O threads claim ownership by wrapping raw pointer in unique_ptr
* - I/O thread optionally passes ownership to a thread pipeline
* - Owner eventually transfers back to epoll by releasing unique_ptr to raw
* pointer
* - RAII cleanup happens if I/O thread doesn't transfer back
*
* Arena allocator thread safety:
* Each Connection contains its own ArenaAllocator instance that is accessed
* exclusively by the thread that currently owns the connection. This ensures
* thread safety without requiring locks:
* - Arena is used by the owning thread for I/O buffers, request parsing, and
* response generation
* - Arena memory is automatically freed when the connection is destroyed
* - reset() should only be called by the current owner thread
*
* Only the handler interface methods are public - all networking details are
* private.
*/
// Forward declaration
struct Server;
struct Connection {
/**
* Shutdown modes for connection termination.
*/
enum class ConnectionShutdown {
None, // Normal operation - no shutdown requested
WriteOnly, // shutdown(SHUT_WR) after sending queued data
Full // close() after sending queued data
};
/**
* Base interface for sending messages to a connection.
* This restricted interface is safe for use by pipeline threads,
* containing only the send_response method needed for responses.
* Pipeline threads should use WeakRef<MessageSender> to safely
* send responses without accessing other connection functionality
* that should only be used by the I/O thread.
*/
struct MessageSender {
/**
* @brief Send response with protocol-specific handle for correlation.
*
* Thread-safe method for pipeline threads to send responses back to clients.
* Delegates to the connection's protocol handler for ordering logic.
* The protocol handler may queue the response or send it immediately.
*
* @param handle Protocol-specific handle for correlating this response
* @param response_json JSON response body (may be empty for deferred
* serialization)
* @param arena Arena containing response data and context
*
* Example usage:
* ```cpp
* ProtocolHandle handle = handler.allocate_response_context(arena);
* conn.send_response(handle, response_json, std::move(arena));
* ```
*/
virtual void send_response(ProtocolHandle handle,
std::string_view response_json, Arena arena) = 0;
virtual ~MessageSender() = default;
};
/**
* Represents a single client connection - the full interface available to the
* io thread and connection handler.
*
* Connection ownership model:
* - Server owns all connections
* - Handlers receive Connection& references, and can keep a WeakRef to
* MessageSender for async responses.
* - Multiple pipeline threads can safely access the MessageSender concurrently
* - I/O thread has exclusive access to socket operations
*
* Threading model:
* - Single mutex protects state shared with pipeline threads
* - Pipeline threads call Connection methods (send_response, etc.)
* - I/O thread processes socket events and message queue
* - Pipeline threads register epoll write interest via send_response
* - Connection tracks closed state to prevent EBADF errors
*
* Arena allocator usage:
* - Request-scoped arenas created by handlers for each request
* - No connection-owned arena for parsing/response generation
* - Message queue stores spans + owning arenas until I/O completion
*/
struct Connection : MessageSender {
// No public constructor or factory method - only Server can create
// connections
@@ -64,90 +108,72 @@ struct Connection {
// Handler interface - public methods that handlers can use
/**
* @brief Queue a message to be sent to the client.
* @brief Queue an atomic message to be sent to the client.
*
* Adds data to the connection's outgoing message queue. The data will be sent
* asynchronously by the server's I/O threads using efficient vectored
* I/O.
* Adds a complete message with all associated data to the connection's
* outgoing byte queue with guaranteed ordering.
*
* @param s The data to send (string view for zero-copy efficiency)
* @param copy_to_arena If true (default), copies data to the connection's
* arena for safe storage. If false, the caller must ensure the data remains
* valid until all queued messages are sent.
* I/O thread only method for protocol handlers to queue bytes for sending.
* Bytes are queued in order and sent using efficient vectored I/O.
*
* @warning Thread Safety: Only call from the thread that currently owns this
* connection. The arena allocator is not thread-safe.
* @param data_parts Span of string_views pointing to arena-allocated data
* @param arena Arena that owns all the memory referenced by data_parts
* @param shutdown_mode Shutdown mode to apply after sending all queued data
*
* @note Performance: Use copy_to_arena=false for static strings or data with
* guaranteed lifetime, copy_to_arena=true for temporary/dynamic data.
* @note Thread Safety: Must be called from I/O thread only.
* @note Ordering: Bytes are sent in the order calls are made.
* @note The memory referenced by the data_parts span, must outlive @p arena.
* @note Shutdown Request: To request connection shutdown without sending
* data, pass empty data_parts span with desired shutdown_mode. This ensures
* all previously queued messages are sent before shutdown.
*
* Example usage (from ConnectionHandler::on_preprocess_writes):
* ```cpp
* Arena arena;
* auto parts = arena.allocate_span<std::string_view>(2);
* parts[0] = build_header(arena);
* parts[1] = build_body(arena);
* conn.append_bytes({parts, 2}, std::move(arena), ConnectionShutdown::None);
* ```
*/
void
append_bytes(std::span<std::string_view> data_parts, Arena arena,
ConnectionShutdown shutdown_mode = ConnectionShutdown::None);
void send_response(ProtocolHandle handle, std::string_view response_json,
Arena arena) override;
/**
* @brief Get a WeakRef to this connection for async operations.
*
* Returns a WeakRef that can be safely used to access this connection
* from other threads, such as pipeline processing threads. The WeakRef
* allows safe access even if the connection might be destroyed by the
* time the async operation executes.
*
* @return WeakRef to this connection
*
* @note Thread Safety: This method is thread-safe.
*
* @note The WeakRef should be used with lock() to safely access the
* connection. If lock() returns null, the connection has been destroyed.
*
* Example usage:
* ```cpp
* conn->append_message("HTTP/1.1 200 OK\r\n\r\n", false); // Static string
* conn->append_message(dynamic_response, true); // Dynamic data
* conn->append_message(arena_allocated_data, false); // Arena data
* auto weak_conn = conn.get_weak_ref();
* async_processor.submit([weak_conn, request_data]() {
* if (auto conn = weak_conn.lock()) {
* Arena arena;
* auto response = process_request(request_data, arena);
* conn->send_response(handle, response_json, std::move(arena));
* }
* });
* ```
*/
void append_message(std::string_view s, bool copy_to_arena = true);
/**
* @brief Mark the connection to be closed after sending all queued messages.
*
* Sets a flag that instructs the server to close this connection gracefully
* after all currently queued messages have been successfully sent to the
* client. This enables proper connection cleanup for protocols like HTTP/1.0
* or when implementing connection limits.
*
* @note The connection will remain active until:
* 1. All queued messages are sent to the client
* 2. The server processes the close flag during the next I/O cycle
* 3. The connection is properly closed and cleaned up
*
* @warning Thread Safety: Only call from the thread that currently owns this
* connection.
*
* Typical usage:
* ```cpp
* conn->append_message("HTTP/1.1 200 OK\r\n\r\nBye!");
* conn->close_after_send(); // Close after sending response
* ```
*/
void close_after_send() { closeConnection_ = true; }
/**
* @brief Get access to the connection's arena allocator.
*
* Returns a reference to this connection's private ArenaAllocator instance,
* which should be used for all temporary allocations during request
* processing. The arena provides extremely fast allocation (~1ns) and
* automatic cleanup when the connection is destroyed or reset.
*
* @return Reference to the connection's arena allocator
*
* @warning Thread Safety: Only access from the thread that currently owns
* this connection. The arena allocator is not thread-safe and concurrent
* access will result in undefined behavior.
*
* @note Memory Lifecycle: Arena memory is automatically freed when:
* - The connection is destroyed
* - reset() is called (keeps first block, frees others)
* - The connection is moved (arena ownership transfers)
*
* Best practices:
* ```cpp
* ArenaAllocator& arena = conn->get_arena();
*
* // Allocate temporary parsing buffers
* char* buffer = arena.allocate<char>(1024);
*
* // Construct temporary objects
* auto* request = arena.construct<HttpRequest>(arena);
*
* // Use arena-backed STL containers
* std::vector<Token, ArenaStlAllocator<Token>> tokens{&arena};
* ```
*/
ArenaAllocator &get_arena() { return arena_; }
WeakRef<MessageSender> get_weak_ref() const {
assert(self_ref_.lock());
return self_ref_.copy();
}
/**
* @brief Get the unique identifier for this connection.
@@ -175,54 +201,6 @@ struct Connection {
*/
int64_t get_id() const { return id_; }
/**
* @brief Get the number of bytes queued for transmission.
*
* Returns the total number of bytes in all messages currently
* queued for transmission to the client. This includes all data added via
* append_message() that has not yet been sent over the network.
*
* @return Total bytes queued for transmission
*
* @warning Thread Safety: Only call from the thread that currently owns this
* connection. Concurrent access to the message queue is not thread-safe.
*
* @note Performance: This method uses an O(1) counter for fast retrieval
* in release builds. In debug builds, validates counter accuracy.
*
* @note The count decreases as the server sends data via writeBytes() and
* removes completed messages from the queue.
*
* Use cases:
* ```cpp
* // Check if all data has been sent
* if (conn->outgoingBytesQueued() == 0) {
* conn->reset(); // Safe to reset arena
* }
*
* // Implement backpressure
* if (conn->outgoingBytesQueued() > MAX_BUFFER_SIZE) {
* // Stop adding more data until queue drains
* }
*
* // Logging/monitoring
* metrics.recordQueueDepth(conn->get_id(), conn->outgoingBytesQueued());
* ```
*/
int64_t outgoingBytesQueued() const {
#ifndef NDEBUG
// Debug build: validate counter accuracy
int64_t computed_total = 0;
for (auto s : messages_) {
computed_total += s.size();
}
assert(
outgoing_bytes_queued_ == computed_total &&
"outgoing_bytes_queued_ counter is out of sync with actual queue size");
#endif
return outgoing_bytes_queued_;
}
/**
* @brief Protocol-specific data pointer for handler use.
*
@@ -245,7 +223,7 @@ struct Connection {
*
* Example usage:
* ```cpp
* class HttpHandler : public ConnectionHandler {
* class HttpHandler : ConnectionHandler {
* void on_connection_established(Connection& conn) override {
* // Allocate HTTP state in connection's arena or heap
* auto* state = conn.get_arena().construct<HttpConnectionState>();
@@ -259,8 +237,8 @@ struct Connection {
* }
*
* void on_data_arrived(std::string_view data,
* std::unique_ptr<Connection>& conn_ptr) override {
* auto* state = static_cast<HttpConnectionState*>(conn_ptr->user_data);
* Connection& conn) override {
* auto* state = static_cast<HttpConnectionState*>(conn.user_data);
* // Use state for protocol processing...
* }
* };
@@ -268,50 +246,13 @@ struct Connection {
*/
void *user_data = nullptr;
/**
* Reset the connection's arena allocator and message queue for reuse.
*
* This method efficiently reclaims arena memory by keeping the first block
* and freeing all others, then reinitializes the message queue.
*
* @warning Thread Safety: This method should ONLY be called by the thread
* that currently owns this connection. Calling reset() while the connection
* is being transferred between threads or accessed by another thread will
* result in undefined behavior.
*
* @note The assert(messages_.empty()) ensures all outgoing data has been
* sent before resetting. This prevents data loss and indicates the connection
* is in a clean state for reuse.
*
* Typical usage pattern:
* - HTTP handlers call this after completing a request/response cycle
*/
void reset() {
assert(messages_.empty());
outgoing_bytes_queued_ = 0;
arena_.reset();
messages_ =
std::deque<std::string_view, ArenaStlAllocator<std::string_view>>{
ArenaStlAllocator<std::string_view>{&arena_}};
}
/**
* @note Ownership Transfer: To release a connection back to the server for
* continued processing, use the static method:
* ```cpp
* Server::release_back_to_server(std::move(connection_ptr));
* ```
*
* This is the correct way to return connection ownership when:
* - A handler has taken ownership via unique_ptr.release()
* - Background processing of the connection is complete
* - The connection should resume normal server-managed I/O processing
*
* The method is thread-safe and handles the case where the server may have
* been destroyed while the connection was being processed elsewhere.
*/
private:
struct Message {
Arena arena; // Owns all the memory (movable)
std::span<std::string_view> data_parts; // Points to arena-allocated memory
// (mutable for partial writes)
};
// Server is a friend and can access all networking internals
friend struct Server;
@@ -320,8 +261,9 @@ private:
*
* Creates a new connection with the specified network address, file
* descriptor, and associated handler. Automatically increments the global
* active connection counter and calls the handler's
* on_connection_established() method.
* active connection counter. The caller (Server) is responsible for
* initializing the self weak reference and invoking
* on_connection_established().
*
* @param addr Network address of the remote client (IPv4/IPv6 compatible)
* @param fd File descriptor for the socket connection
@@ -330,32 +272,46 @@ private:
* @param server Reference to server associated with this connection
*/
Connection(struct sockaddr_storage addr, int fd, int64_t id,
size_t epoll_index, ConnectionHandler *handler, Server &server);
size_t epoll_index, ConnectionHandler *handler,
WeakRef<Server> server);
template <typename T, typename... Args>
friend Ref<T> make_ref(Args &&...args);
// Networking interface - only accessible by Server
int readBytes(char *buf, size_t buffer_size);
bool writeBytes();
int read_bytes(char *buf, size_t buffer_size);
enum WriteBytesResult {
Error = 1 << 0,
Progress = 1 << 1,
Close = 1 << 2,
};
uint32_t write_bytes();
// Direct access methods for Server
int getFd() const { return fd_; }
bool hasMessages() const { return !messages_.empty(); }
bool shouldClose() const { return closeConnection_; }
size_t getEpollIndex() const { return epoll_index_; }
const int fd_;
void close();
// Immutable connection properties
const int64_t id_;
const size_t epoll_index_; // Index of the epoll instance this connection uses
struct sockaddr_storage addr_; // sockaddr_storage handles IPv4/IPv6
ArenaAllocator arena_;
ConnectionHandler *handler_;
std::weak_ptr<Server> server_; // Weak reference to server for safe cleanup
ConnectionHandler *const handler_;
WeakRef<Server> server_; // Weak reference to server for safe epoll_ctl calls
WeakRef<Connection> self_ref_; // WeakRef to self for get_weak_ref()
std::deque<std::string_view, ArenaStlAllocator<std::string_view>> messages_{
ArenaStlAllocator<std::string_view>{&arena_}};
// Only accessed from io thread
std::deque<Message> message_queue_;
// Counter tracking total bytes queued for transmission
int64_t outgoing_bytes_queued_{0};
mutable std::mutex mutex_;
ConnectionShutdown shutdown_requested_{
ConnectionShutdown::None}; // Protected by mutex_
std::deque<PendingResponse> pending_response_queue_; // Protected by mutex_
int fd_; // Protected by mutex_
// Whether or not to close the connection after completing writing the
// response
bool closeConnection_{false};
#if __has_feature(thread_sanitizer)
void tsan_acquire() { tsan_sync.load(std::memory_order_acquire); }
void tsan_release() { tsan_sync.store(0, std::memory_order_release); }
std::atomic<int> tsan_sync;
#else
void tsan_acquire() {}
void tsan_release() {}
#endif
};
+58 -45
View File
@@ -1,12 +1,30 @@
#pragma once
#include <memory>
#include <span>
#include <string_view>
// Forward declaration to avoid circular dependency
// Forward declarations to avoid circular dependency
struct Connection;
// Include Arena header since PendingResponse uses Arena by value
#include "arena.hpp"
#include <cstdint>
// Opaque handle used to correlate responses with protocol-specific state.
// Each ConnectionHandler implementation defines the meaning of a handle value
// and resolves it back to full context in on_preprocess_writes().
using ProtocolHandle = int64_t;
/**
* Represents a response queued by pipeline threads for protocol processing.
* Contains JSON response data that can be wrapped by any protocol.
*/
struct PendingResponse {
ProtocolHandle handle; // Protocol-specific handle for correlation
std::string_view response_json; // JSON response body (arena-allocated)
Arena arena; // Arena containing response data and context
};
/**
* Abstract interface for handling connection data processing.
*
@@ -25,22 +43,21 @@ public:
* Process incoming data from a connection.
*
* @param data Incoming data buffer (may be partial message)
* @param conn_ptr Unique pointer to connection - handler can take ownership
* by releasing it
* @param conn Connection reference - server retains ownership
*
* Implementation should:
* - Parse incoming data using arena allocator when needed
* - Use conn_ptr->append_message() to queue response data to be sent
* - Create request-scoped Arena for parsing and response generation
* - Parse incoming data using the request arena
* - Use conn.send_response() to queue response data to be sent
* - Handle partial messages and streaming protocols appropriately
* - Can take ownership by calling conn_ptr.release() to pass to other threads
* - If ownership is taken, handler must call Server::release_back_to_server()
* when done
* @note `data` is *not* owned by the connection arena, and its lifetime ends
* after the call to on_data_arrived.
* @note May be called from an arbitrary server thread.
* - Use conn.get_weak_ref() for async processing if needed
*
* @note `data` lifetime ends after the call to on_data_arrived.
* @note Called from this connection's io thread.
* @note Handler can safely access connection concurrently via thread-safe
* methods.
*/
virtual void on_data_arrived(std::string_view /*data*/,
std::unique_ptr<Connection> &) {};
virtual void on_data_arrived(std::string_view /*data*/, Connection &) {};
/**
* Called when data has been successfully written to the connection.
@@ -50,29 +67,12 @@ public:
* - Implementing backpressure for continuous data streams
* - Progress monitoring for long-running transfers
*
* @param conn_ptr Connection that made write progress - handler can take
* ownership
* @note May be called from an arbitrary server thread.
* @param conn Connection that made write progress - server retains ownership
* @note Called from this connection's io thread.
* @note Called during writes, not necessarily when buffer becomes empty
* TODO Add bytes written argument?
*/
virtual void on_write_progress(std::unique_ptr<Connection> &) {}
/**
* Called when the connection's outgoing write buffer becomes empty.
*
* This indicates all queued messages have been successfully written
* to the socket. Useful for:
* - Resetting arena allocators safely
* - Implementing keep-alive connection reuse
* - Closing connections after final response
* - Relieving backpressure conditions
*
* @param conn_ptr Connection with empty write buffer - handler can take
* ownership
* @note May be called from an arbitrary server thread.
* @note Only called on transitions from non-empty empty buffer
*/
virtual void on_write_buffer_drained(std::unique_ptr<Connection> &) {}
virtual void on_write_progress(Connection &) {}
/**
* Called when a new connection is established.
@@ -81,7 +81,7 @@ public:
*
* Use this for:
* - Connection-specific initialization.
* @note May be called from an arbitrary server thread.
* @note Called from this connection's io thread.
*/
virtual void on_connection_established(Connection &) {}
@@ -92,21 +92,34 @@ public:
*
* Use this for:
* - Cleanup of connection-specific resources.
* @note May be called from an arbitrary server thread.
* @note Called from this connection's io thread, or possibly a foreign thread
* that has locked the MessageSender associated with this connection.
*/
virtual void on_connection_closed(Connection &) {}
/**
* @brief Called after a batch of connections has been processed.
*
* This hook is called after on_data_arrived, on_write_progress, or
* on_write_buffer_drained has been called for each connection in the batch.
* The handler can take ownership of the connections by moving the unique_ptr
* out of the span. Any connections left in the span will remain owned by the
* server.
* This hook is called after on_data_arrived or on_write_progress has been
* called for each connection in the batch. All connections remain
* server-owned.
*
* @param batch A span of unique_ptrs to the connections in the batch.
* @param batch A span of connection references in the batch.
* @note Called from this connection's io thread.
*/
virtual void
on_batch_complete(std::span<std::unique_ptr<Connection>> /*batch*/) {}
virtual void on_batch_complete(std::span<Connection *const> /*batch*/) {}
/**
* Called before processing outgoing writes on a connection.
*
* This hook allows protocol handlers to process queued responses
* before actual socket writes occur. Used for response ordering,
* serialization, and other preprocessing.
*
* @param conn Connection about to write data
* @param pending_responses Responses queued by pipeline threads
* @note Called from this connection's io thread.
* @note Called when EPOLLOUT event occurs
*/
virtual void on_preprocess_writes(Connection &, std::span<PendingResponse>) {}
};
+30 -30
View File
@@ -1,6 +1,5 @@
#include "connection_registry.hpp"
#include "connection.hpp"
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
@@ -14,49 +13,50 @@ ConnectionRegistry::ConnectionRegistry() : connections_(nullptr), max_fds_(0) {
}
max_fds_ = rlim.rlim_cur;
// Calculate size rounded up to page boundary
size_t array_size = max_fds_ * sizeof(Connection *);
size_t page_size = getpagesize();
size_t aligned_size = (array_size + page_size - 1) & ~(page_size - 1);
// TODO re-enable "ondemand pages" behavior
// // Calculate size rounded up to page boundary
// size_t array_size = max_fds_ * sizeof(Connection *);
// size_t page_size = getpagesize();
// size_t aligned_size = (array_size + page_size - 1) & ~(page_size - 1);
// Allocate virtual address space using mmap
// MAP_ANONYMOUS provides zero-initialized pages on-demand (lazy allocation)
connections_ = static_cast<std::atomic<Connection *> *>(
mmap(nullptr, aligned_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
// // Allocate virtual address space using mmap
// // MAP_ANONYMOUS provides zero-initialized pages on-demand (lazy
// allocation) connections_ = static_cast<std::atomic<Connection *> *>(
// mmap(nullptr, aligned_size, PROT_READ | PROT_WRITE,
// MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
if (connections_ == MAP_FAILED) {
perror("mmap");
std::abort();
}
// if (connections_ == MAP_FAILED) {
// perror("mmap");
// std::abort();
// }
// Store aligned size for munmap
aligned_size_ = aligned_size;
// // Store aligned size for munmap
// aligned_size_ = aligned_size;
connections_ = new Ref<Connection>[max_fds_];
}
ConnectionRegistry::~ConnectionRegistry() {
if (connections_ != nullptr) {
for (int fd = 0; fd < static_cast<int>(max_fds_); ++fd) {
delete connections_[fd].load(std::memory_order_relaxed);
}
if (munmap(connections_, aligned_size_) == -1) {
perror("munmap");
}
}
delete[] connections_;
// if (connections_ != nullptr) {
// for (int fd = 0; fd < static_cast<int>(max_fds_); ++fd) {
// delete connections_[fd].load(std::memory_order_relaxed);
// }
// if (munmap(connections_, aligned_size_) == -1) {
// perror("munmap");
// }
// }
}
void ConnectionRegistry::store(int fd, std::unique_ptr<Connection> connection) {
void ConnectionRegistry::store(int fd, Ref<Connection> connection) {
if (fd < 0 || static_cast<size_t>(fd) >= max_fds_) {
std::abort();
}
// Release ownership from unique_ptr and store raw pointer
connections_[fd].store(connection.release(), std::memory_order_release);
connections_[fd] = std::move(connection);
}
std::unique_ptr<Connection> ConnectionRegistry::remove(int fd) {
Ref<Connection> ConnectionRegistry::remove(int fd) {
if (fd < 0 || static_cast<size_t>(fd) >= max_fds_) {
std::abort();
}
return std::unique_ptr<Connection>(
connections_[fd].exchange(nullptr, std::memory_order_acquire));
return std::move(connections_[fd]);
}
+7 -9
View File
@@ -1,10 +1,11 @@
#pragma once
#include <cstddef>
#include <memory>
#include <sys/mman.h>
#include <sys/resource.h>
#include "reference.hpp"
struct Connection;
/**
@@ -33,12 +34,12 @@ public:
/**
* Store a connection in the registry, indexed by its file descriptor.
* Takes ownership of the connection via unique_ptr.
* Takes a reference to the connection for storage.
*
* @param fd File descriptor (must be valid and < max_fds_)
* @param connection unique_ptr to the connection (ownership transferred)
* @param connection Ref<Connection> to store in the registry
*/
void store(int fd, std::unique_ptr<Connection> connection);
void store(int fd, Ref<Connection> connection);
/**
* Remove a connection from the registry and transfer ownership to caller.
@@ -47,7 +48,7 @@ public:
* @param fd File descriptor
* @return unique_ptr to the connection, or nullptr if not found
*/
std::unique_ptr<Connection> remove(int fd);
Ref<Connection> remove(int fd);
/**
* Get the maximum number of file descriptors supported.
@@ -63,10 +64,7 @@ public:
ConnectionRegistry &operator=(ConnectionRegistry &&) = delete;
private:
std::atomic<Connection *>
*connections_; ///< mmap'd array of raw connection pointers. It's
///< thread-safe without since epoll_ctl happens before
///< epoll_wait, but this makes tsan happy /shrug.
Ref<Connection> *connections_;
size_t max_fds_; ///< Maximum file descriptor limit
size_t aligned_size_; ///< Page-aligned size for munmap
};
+61
View File
@@ -0,0 +1,61 @@
#include "cpu_work.hpp"
#if defined(__x86_64__) || defined(__amd64__)
// x86-64 file-scoped assembly implementation
#ifdef __APPLE__
asm(".text\n"
".globl _spend_cpu_cycles\n"
"_spend_cpu_cycles:\n"
" test %edi, %edi\n" // Test if iterations <= 0
" jle .L_end\n" // Jump to end if <= 0
".L_loop:\n" // Loop start
" dec %edi\n" // Decrement iterations
" jnz .L_loop\n" // Jump back if not zero
".L_end:\n" // End
" ret\n" // Return
);
#else
asm(".text\n"
".globl spend_cpu_cycles\n"
".type spend_cpu_cycles, @function\n"
"spend_cpu_cycles:\n"
" test %edi, %edi\n" // Test if iterations <= 0
" jle .L_end\n" // Jump to end if <= 0
".L_loop:\n" // Loop start
" dec %edi\n" // Decrement iterations
" jnz .L_loop\n" // Jump back if not zero
".L_end:\n" // End
" ret\n" // Return
".size spend_cpu_cycles, .-spend_cpu_cycles\n");
#endif
#elif defined(__aarch64__)
// ARM64 file-scoped assembly implementation
#ifdef __APPLE__
asm(".text\n"
".globl _spend_cpu_cycles\n"
"_spend_cpu_cycles:\n"
" cmp w0, wzr\n" // Compare iterations with zero
" b.le .L_end\n" // Branch to end if <= 0
".L_loop:\n" // Loop start
" subs w0, w0, #1\n" // Decrement iterations and set flags
" b.ne .L_loop\n" // Branch back if not zero
".L_end:\n" // End
" ret\n" // Return
);
#else
asm(".text\n"
".globl spend_cpu_cycles\n"
".type spend_cpu_cycles, %function\n"
"spend_cpu_cycles:\n"
" cmp w0, wzr\n" // Compare iterations with zero
" b.le .L_end\n" // Branch to end if <= 0
".L_loop:\n" // Loop start
" subs w0, w0, #1\n" // Decrement iterations and set flags
" b.ne .L_loop\n" // Branch back if not zero
".L_end:\n" // End
" ret\n" // Return
".size spend_cpu_cycles, .-spend_cpu_cycles\n");
#endif
#endif
+24
View File
@@ -0,0 +1,24 @@
#pragma once
extern "C" {
/**
* @brief Perform CPU-intensive work for benchmarking and health check purposes.
*
* This function performs a deterministic amount of CPU work that cannot be
* optimized away by the compiler. It's used both in the health check resolve
* stage and in benchmarks to measure the actual CPU time consumed.
*
* @param iterations Number of loop iterations to perform
*/
void spend_cpu_cycles(int iterations);
}
/**
* @brief Default CPU work iterations for health check benchmarking.
*
* Represents the number of CPU-intensive loop iterations used in the
* /ok health check resolve stage. This value provides 650ns of CPU work
* and achieves 1M requests/second throughput through the 4-stage pipeline.
*/
constexpr int DEFAULT_HEALTH_CHECK_ITERATIONS = 4000;
+1004
View File
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
#pragma once
#include <concepts>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <string_view>
#include <type_traits>
#include "arena.hpp"
/**
* @brief Runtime printf-style formatting with arena allocation optimization.
*
* This function provides familiar printf-style formatting with intelligent
* optimization for arena allocation. It attempts single-pass formatting by
* speculatively using available arena space, falling back to two-pass
* formatting only when necessary.
*
* The function uses an optimized allocation strategy:
* 1. **Single-pass attempt**: Try to format directly into available arena space
* 2. **Fallback to two-pass**: If formatting doesn't fit, measure required size
* and allocate exactly what's needed
*
* ## Supported Format Specifiers:
* All standard printf format specifiers are supported:
* - **Integers**: %d, %i, %u, %x, %X, %o, %ld, %lld, etc.
* - **Floating point**: %f, %e, %E, %g, %G, %.2f, etc.
* - **Strings**: %s, %.*s, etc.
* - **Characters**: %c
* - **Pointers**: %p
* - **Width/precision**: %10d, %-10s, %.2f, %*.*s, etc.
*
* ## Performance Characteristics:
* - **Optimistic single-pass**: Often avoids the cost of measuring format size
* - **Arena allocation**: Uses fast arena allocation (~1ns vs ~20-270ns for
* malloc)
* - **Memory efficient**: Returns unused space to arena via realloc()
* - **Fallback safety**: Two-pass approach handles any format that doesn't fit
*
* @param arena Arena allocator for memory management
* @param fmt Printf-style format string
* @param ... Variable arguments matching format specifiers
* @return std::string_view pointing to arena-allocated formatted string
* @note Aborts program on formatting errors (never returns invalid data)
* @note GCC format attribute enables compile-time format string validation
*
* ## Usage Examples:
* ```cpp
* Arena arena(1024);
*
* // Basic formatting
* auto msg = format(arena, "Hello %s!", "World");
* // msg == "Hello World!"
*
* // Numeric formatting with precision
* auto value = format(arena, "Pi: %.3f", 3.14159);
* // value == "Pi: 3.142"
*
* // Mixed types with width/alignment
* auto table = format(arena, "%-10s %5d %8.2f", "Item", 42, 99.95);
* // table == "Item 42 99.95"
*
* // Error messages
* auto error = format(arena, "Error %d: %s (line %d)", 404, "Not found", 123);
* // error == "Error 404: Not found (line 123)"
* ```
*
* ## When to Use:
* - **Printf familiarity**: When you prefer printf-style format strings
* - **Runtime flexibility**: Format strings from variables, config, or user
* input
* - **Complex formatting**: Precision, width, alignment, padding
* - **Debugging**: Quick formatted output for logging/debugging
* - **Mixed precision**: Different numeric precision requirements
*
* ## When to Use static_format() Instead:
* - **Hot paths**: Performance-critical code where every nanosecond counts
* - **Simple concatenation**: Basic string + number + string combinations
* - **Compile-time optimization**: When all types/values known at compile time
* - **Template contexts**: Where compile-time buffer sizing is beneficial
* - **IMPORTANT**: Only works with compile-time string literals, NOT runtime
* const char*
*
* ## Optimization Details:
* The function uses `Arena::allocate_remaining_space()` to claim all
* available arena space and attempt formatting. If successful, it shrinks the
* allocation to the actual size used. If formatting fails (doesn't fit), it
* falls back to the traditional two-pass approach: measure size, allocate
* exactly, then format.
*
* This strategy optimizes for the common case where available arena space is
* sufficient, while maintaining correctness for all cases.
*/
std::string_view format(Arena &arena, const char *fmt, ...)
__attribute__((format(printf, 2, 3)));
namespace detail {
template <int kLen> struct StringTerm {
explicit constexpr StringTerm(const char *s) : s(s) {}
static constexpr int kMaxLength = kLen;
void write(char *&buf) const {
std::memcpy(buf, s, kLen);
buf += kLen;
}
private:
const char *s;
};
template <int kLen>
constexpr StringTerm<kLen - 1> term(const char (&array)[kLen]) {
return StringTerm<kLen - 1>{array};
}
template <class IntType> constexpr int decimal_length(IntType x) {
static_assert(std::is_integral_v<IntType>,
"decimal_length requires integral type");
if constexpr (std::is_signed_v<IntType>) {
// Handle negative values by using unsigned equivalent
using Unsigned = std::make_unsigned_t<IntType>;
// Safe conversion: cast to unsigned first, then negate in unsigned
// arithmetic
auto abs_x = x < 0 ? -static_cast<Unsigned>(x) : static_cast<Unsigned>(x);
int result = 0;
do {
++result;
abs_x /= 10;
} while (abs_x);
return result;
} else {
int result = 0;
do {
++result;
x /= 10;
} while (x);
return result;
}
}
template <std::integral IntType> struct IntTerm {
static constexpr bool kSigned = std::is_signed_v<IntType>;
using Unsigned = std::make_unsigned_t<IntType>;
explicit constexpr IntTerm(IntType v) : v(v) {}
static constexpr int kMaxLength =
decimal_length(Unsigned(-1)) + (kSigned ? 1 : 0);
void write(char *&buf) const {
char itoa_buf[kMaxLength];
auto x = static_cast<Unsigned>(v);
if constexpr (kSigned) {
if (v < 0) {
*buf++ = '-';
x = -static_cast<Unsigned>(v);
}
}
int i = kMaxLength;
do {
itoa_buf[--i] = static_cast<char>('0' + (x % 10));
x /= 10;
} while (x);
while (i < kMaxLength) {
*buf++ = itoa_buf[i++];
}
}
private:
IntType v;
};
template <std::integral IntType> constexpr IntTerm<IntType> term(IntType s) {
return IntTerm<IntType>{s};
}
struct DoubleTerm {
explicit constexpr DoubleTerm(double s) : s(s) {}
static constexpr int kMaxLength = 24;
void write(char *&buf) const;
private:
double s;
};
// Variable template for compile-time max length access
template <typename T>
inline constexpr int max_decimal_length_v = decltype(term(T{}))::kMaxLength;
inline constexpr DoubleTerm term(double s) { return DoubleTerm(s); }
} // namespace detail
/**
* @brief Compile-time optimized formatting for high-performance code paths.
*
* This function provides ultra-fast string formatting by calculating buffer
* sizes at compile time and using specialized term handlers for each type.
* It's designed for performance-critical code where formatting overhead
* matters.
*
* Unlike the runtime `format()` function, `static_format()` processes all
* arguments at compile time to determine exact memory requirements and uses
* optimized term writers for maximum speed.
*
* ## Supported Types:
* - **String literals**: C-style string literals and arrays ("Hello", "World")
* - **Integers**: All integral types (int, int64_t, uint32_t, etc.)
* - **Floating point**: double (uses high-precision Grisu2 algorithm)
* - **Custom types**: Via specialization of `detail::term()`
* - **NOT supported**: const char* variables, std::string, std::string_view
* variables
*
* ## Performance Characteristics:
* - **Compile-time buffer sizing**: Buffer size calculated at compile time (no
* runtime measurement)
* - **Optimized arena allocation**: Uses pre-calculated exact buffer sizes with
* arena allocator
* - **Specialized type handling**: Fast paths for common types via template
* specialization
* - **Memory efficient**: Uses arena.realloc() to return unused space to the
* arena
*
* @tparam Ts Types of the arguments to format (auto-deduced)
* @param arena Arena allocator for memory management
* @param ts Arguments to format - can be string literals, integers, doubles
* @return std::string_view pointing to arena-allocated formatted string
*
* ## Usage Examples:
* ```cpp
* Arena arena(1024);
*
* // String concatenation
* auto result1 = static_format(arena, "Hello ", "World", "!");
* // result1 == "Hello World!"
*
* // Mixed types
* auto result2 = static_format(arena, "Count: ", 42, ", Rate: ", 3.14);
* // result2 == "Count: 42, Rate: 3.14"
*
* // Error messages
* auto error = static_format(arena, "Error ", 404, ": ", "Not found");
* // error == "Error 404: Not found"
* ```
*
* ## When to Use:
* - **Hot paths**: Performance-critical code where formatting speed matters
* - **Compile-time string literals**: All string arguments must be string
* literals (e.g., "Hello")
* - **Simple formatting**: Concatenation and basic type conversion
* - **Template code**: Where compile-time optimization is beneficial
* - **CANNOT use runtime strings**: No const char*, std::string, or string_view
* variables
*
* ## When to Use format() Instead:
* - **Printf-style formatting**: When you need format specifiers like "%d",
* "%.2f"
* - **Runtime strings**: When you have const char*, std::string, or string_view
* variables
* - **Dynamic content**: When format strings come from variables/config/user
* input
* - **Complex formatting**: When you need padding, precision, width specifiers
* - **Mixed literal/runtime**: When combining string literals with runtime
* string data
*
* @note All arguments are passed by forwarding reference for optimal
* performance
* @note Memory is arena-allocated and automatically sized to exact requirements
* @note Compile-time errors occur if unsupported types are used
* @note This function is constexpr-friendly and optimizes well in release
* builds
*/
template <class... Ts>
std::string_view static_format(Arena &arena, Ts &&...ts) {
constexpr int upper_bound = (decltype(detail::term(ts))::kMaxLength + ...);
char *result = arena.allocate<char>(upper_bound);
char *buf = result;
(detail::term(ts).write(buf), ...);
const int size = static_cast<int>(buf - result);
return std::string_view(arena.realloc(result, upper_bound, size),
static_cast<std::size_t>(size));
}
+561 -241
View File
@@ -1,14 +1,35 @@
#include "http_handler.hpp"
#include "arena_allocator.hpp"
#include "perfetto_categories.hpp"
#include <cstring>
#include <string>
#include <strings.h>
// HttpConnectionState implementation
HttpConnectionState::HttpConnectionState(ArenaAllocator &arena)
: current_header_field_buf(ArenaStlAllocator<char>(&arena)),
current_header_value_buf(ArenaStlAllocator<char>(&arena)) {
#include "api_url_parser.hpp"
#include "arena.hpp"
#include "connection.hpp"
#include "format.hpp"
#include "json_commit_request_parser.hpp"
#include "metric.hpp"
#include "pipeline_entry.hpp"
auto requests_counter_family = metric::create_counter(
"weaseldb_http_requests_total", "Total http requests");
thread_local auto metrics_counter =
requests_counter_family.create({{"path", "/metrics"}});
// API endpoint request counters
thread_local auto commit_counter =
requests_counter_family.create({{"path", "/v1/commit"}});
thread_local auto status_counter =
requests_counter_family.create({{"path", "/v1/status"}});
thread_local auto version_counter =
requests_counter_family.create({{"path", "/v1/version"}});
thread_local auto ok_counter =
requests_counter_family.create({{"path", "/ok"}});
thread_local auto not_found_counter =
requests_counter_family.create({{"path", "not_found"}});
HttpConnectionState::HttpConnectionState() {
llhttp_settings_init(&settings);
// Set up llhttp callbacks
@@ -22,58 +43,238 @@ HttpConnectionState::HttpConnectionState(ArenaAllocator &arena)
settings.on_message_complete = HttpHandler::onMessageComplete;
llhttp_init(&parser, HTTP_REQUEST, &settings);
parser.data = this;
parser.data = &pending;
}
// HttpConnectionState implementation
HttpRequestState::HttpRequestState()
: url(ArenaStlAllocator<char>(&arena)),
current_header_field_buf(ArenaStlAllocator<char>(&arena)),
current_header_value_buf(ArenaStlAllocator<char>(&arena)) {}
// HttpConnectionState implementation
HttpConnectionState::~HttpConnectionState() = default;
void HttpConnectionState::register_response_context(
int64_t sequence_id, std::unique_ptr<HttpResponseContext> ctx) {
contexts_[sequence_id] = std::move(ctx);
}
HttpResponseContext *
HttpConnectionState::resolve_response_context(ProtocolHandle handle) {
auto it = contexts_.find(handle);
if (it == contexts_.end()) {
return nullptr;
}
return it->second.get();
}
void HttpConnectionState::send_ordered_response(
Connection &conn, HttpResponseContext *ctx,
std::span<std::string_view> http_response, Arena arena) {
assert(ctx);
int64_t sequence_id = ctx->sequence_id;
std::unique_ptr<HttpResponseContext> owned_ctx;
auto it = contexts_.find(sequence_id);
if (it != contexts_.end()) {
owned_ctx = std::move(it->second);
} else {
owned_ctx.reset(ctx);
}
owned_ctx->ready = true;
owned_ctx->data = http_response;
owned_ctx->arena = std::move(arena);
contexts_[sequence_id] = std::move(owned_ctx);
// Process ready responses in order and send via append_bytes
auto iter = contexts_.begin();
while (iter != contexts_.end() && iter->first == next_sequence_to_send) {
auto *context = iter->second.get();
if (!context || !context->ready) {
break;
}
// Send through append_bytes which handles write interest
conn.append_bytes(context->data, std::move(context->arena),
context->connection_close ? ConnectionShutdown::WriteOnly
: ConnectionShutdown::None);
next_sequence_to_send++;
iter = contexts_.erase(iter);
}
}
// HttpHandler implementation
void HttpHandler::on_connection_established(Connection &conn) {
// Allocate HTTP state in connection's arena
ArenaAllocator &arena = conn.get_arena();
void *mem = arena.allocate_raw(sizeof(HttpConnectionState),
alignof(HttpConnectionState));
auto *state = new (mem) HttpConnectionState(arena);
// Allocate HTTP state using server-provided arena for connection lifecycle
auto *state = new HttpConnectionState();
conn.user_data = state;
}
void HttpHandler::on_connection_closed(Connection &conn) {
// Arena cleanup happens automatically when connection is destroyed
auto *state = static_cast<HttpConnectionState *>(conn.user_data);
state->~HttpConnectionState();
delete state;
conn.user_data = nullptr;
}
void HttpHandler::on_write_buffer_drained(
std::unique_ptr<Connection> &conn_ptr) {
// Reset arena after all messages have been written for the next request
on_connection_closed(*conn_ptr);
conn_ptr->reset();
on_connection_established(*conn_ptr);
}
void HttpHandler::on_preprocess_writes(
Connection &conn, std::span<PendingResponse> pending_responses) {
auto *state = static_cast<HttpConnectionState *>(conn.user_data);
void HttpHandler::on_batch_complete(
std::span<std::unique_ptr<Connection>> batch) {
int readyCount = 0;
for (int i = 0; i < int(batch.size()); ++i) {
readyCount += batch[i] && batch[i]->outgoingBytesQueued() > 0;
// Process incoming responses and add to reorder queue
{
for (auto &pending : pending_responses) {
auto *ctx = state->resolve_response_context(pending.handle);
// Handle stale or invalid handles defensively
if (!ctx) {
continue;
}
if (readyCount > 0) {
auto guard = pipeline.push(readyCount, /*block=*/true);
auto outIter = guard.batch.begin();
for (int i = 0; i < int(batch.size()); ++i) {
if (batch[i] && batch[i]->outgoingBytesQueued() > 0) {
*outIter++ = std::move(batch[i]);
// Determine HTTP status code and content type from response content
int status_code = 200;
std::string_view content_type = "application/json";
// For health checks, detect plain text responses
if (pending.response_json == "OK") {
content_type = "text/plain";
}
// For metrics, detect Prometheus format (starts with # or contains metric
// names)
else if (pending.response_json.starts_with("#") ||
pending.response_json.find("_total") != std::string_view::npos ||
pending.response_json.find("_counter") !=
std::string_view::npos) {
content_type = "text/plain; version=0.0.4";
}
// Format HTTP response from JSON
auto http_response = format_response(
status_code, content_type, pending.response_json, pending.arena,
ctx->http_request_id, ctx->connection_close);
state->send_ordered_response(conn, ctx, http_response,
std::move(pending.arena));
}
}
}
void HttpHandler::on_data_arrived(std::string_view data,
std::unique_ptr<Connection> &conn_ptr) {
auto *state = static_cast<HttpConnectionState *>(conn_ptr->user_data);
if (!state) {
sendErrorResponse(*conn_ptr, 500, "Internal server error", true);
return;
static thread_local std::vector<PipelineEntry> g_batch_entries;
void HttpHandler::on_batch_complete(std::span<Connection *const> batch) {
// Count commit, status, and health check requests
for (auto conn : batch) {
auto *state = static_cast<HttpConnectionState *>(conn->user_data);
for (auto &req : state->queue) {
// Assign sequence ID for response ordering
int64_t sequence_id = state->get_next_sequence_id();
req.sequence_id = sequence_id;
// Create HttpResponseContext for this request; sequence_id doubles as the
// ProtocolHandle for async response correlation. The context is
// registered in HttpConnectionState so async responses can resolve it by
// handle.
auto ctx = std::make_unique<HttpResponseContext>();
ctx->sequence_id = sequence_id;
ctx->http_request_id = req.http_request_id;
ctx->connection_close = req.connection_close;
HttpResponseContext *ctx_ptr = ctx.get();
req.response_context = ctx_ptr;
state->register_response_context(sequence_id, std::move(ctx));
ProtocolHandle handle = sequence_id;
RouteMatch route_match;
auto parse_result =
ApiUrlParser::parse(req.method, const_cast<char *>(req.url.data()),
static_cast<int>(req.url.size()), route_match);
if (parse_result != ParseResult::Success) {
// Handle malformed URL encoding
auto json_response = R"({"error":"Malformed URL encoding"})";
auto http_response =
format_json_response(400, json_response, req.arena, 0, true);
ctx_ptr->connection_close = true;
state->send_ordered_response(*conn, ctx_ptr, http_response,
std::move(req.arena));
break;
}
req.route = route_match.route;
// Route to appropriate handler
switch (req.route) {
case HttpRoute::GetVersion:
handle_get_version(*conn, req);
break;
case HttpRoute::PostCommit:
handle_post_commit(*conn, req);
break;
case HttpRoute::GetSubscribe:
handle_get_subscribe(*conn, req);
break;
case HttpRoute::GetStatus:
handle_get_status(*conn, req, route_match);
break;
case HttpRoute::PutRetention:
handle_put_retention(*conn, req, route_match);
break;
case HttpRoute::GetRetention:
handle_get_retention(*conn, req, route_match);
break;
case HttpRoute::DeleteRetention:
handle_delete_retention(*conn, req, route_match);
break;
case HttpRoute::GetMetrics:
handle_get_metrics(*conn, req);
break;
case HttpRoute::GetOk:
handle_get_ok(*conn, req);
break;
case HttpRoute::NotFound:
default:
handle_not_found(*conn, req);
break;
}
// Create CommitEntry for commit requests
if (req.route == HttpRoute::PostCommit && req.commit_request &&
req.parsing_commit && req.basic_validation_passed) {
g_batch_entries.emplace_back(CommitEntry(conn->get_weak_ref(), handle,
req.commit_request.get(),
std::move(req.arena)));
}
// Create StatusEntry for status requests
else if (req.route == HttpRoute::GetStatus) {
g_batch_entries.emplace_back(StatusEntry(conn->get_weak_ref(), handle,
req.status_request_id,
std::move(req.arena)));
}
// Create HealthCheckEntry for health check requests
else if (req.route == HttpRoute::GetOk) {
g_batch_entries.emplace_back(HealthCheckEntry(
conn->get_weak_ref(), handle, std::move(req.arena)));
}
// Create GetVersionEntry for version requests
else if (req.route == HttpRoute::GetVersion) {
g_batch_entries.emplace_back(
GetVersionEntry(conn->get_weak_ref(), handle, std::move(req.arena),
commit_pipeline_.get_committed_version()));
}
}
state->queue.clear();
}
// Send requests to commit pipeline in batch. Batching here reduces
// contention on the way into the pipeline.
if (!g_batch_entries.empty()) {
commit_pipeline_.submit_batch(g_batch_entries);
}
g_batch_entries.clear();
}
void HttpHandler::on_data_arrived(std::string_view data, Connection &conn) {
auto *state = static_cast<HttpConnectionState *>(conn.user_data);
assert(state);
// TODO: Enforce the configured max_request_size_bytes limit here.
// Should track cumulative bytes received for the current HTTP request
@@ -81,279 +282,376 @@ void HttpHandler::on_data_arrived(std::string_view data,
// This prevents DoS attacks via oversized HTTP requests.
// Parse HTTP data with llhttp
for (;;) {
enum llhttp_errno err =
llhttp_execute(&state->parser, data.data(), data.size());
if (err != HPE_OK) {
sendErrorResponse(*conn_ptr, 400, "Bad request", true);
if (err == HPE_PAUSED) {
assert(state->pending.message_complete);
state->queue.push_back(std::move(state->pending));
state->pending = {};
int consumed = llhttp_get_error_pos(&state->parser) - data.data();
data = data.substr(consumed, data.size() - consumed);
llhttp_resume(&state->parser);
continue;
}
if (err == HPE_OK) {
break;
}
// Parse error - send response directly since this is before sequence
// assignment. Use a temporary response context to carry metadata.
auto json_response = R"({"error":"Bad request"})";
auto ctx = std::make_unique<HttpResponseContext>();
ctx->sequence_id = state->get_next_sequence_id();
ctx->http_request_id = 0;
ctx->connection_close = true;
auto http_response =
format_json_response(400, json_response, state->pending.arena,
ctx->http_request_id, ctx->connection_close);
state->send_ordered_response(conn, ctx.get(), http_response,
std::move(state->pending.arena));
return;
}
// If message is complete, route and handle the request
if (state->message_complete) {
// Parse route from method and URL
state->route = parseRoute(state->method, state->url);
// Route to appropriate handler
switch (state->route) {
case HttpRoute::GET_version:
handleGetVersion(*conn_ptr, *state);
break;
case HttpRoute::POST_commit:
handlePostCommit(*conn_ptr, *state);
break;
case HttpRoute::GET_subscribe:
handleGetSubscribe(*conn_ptr, *state);
break;
case HttpRoute::GET_status:
handleGetStatus(*conn_ptr, *state);
break;
case HttpRoute::PUT_retention:
handlePutRetention(*conn_ptr, *state);
break;
case HttpRoute::GET_retention:
handleGetRetention(*conn_ptr, *state);
break;
case HttpRoute::DELETE_retention:
handleDeleteRetention(*conn_ptr, *state);
break;
case HttpRoute::GET_metrics:
handleGetMetrics(*conn_ptr, *state);
break;
case HttpRoute::GET_ok:
handleGetOk(*conn_ptr, *state);
break;
case HttpRoute::NotFound:
default:
handleNotFound(*conn_ptr, *state);
break;
}
}
}
HttpRoute HttpHandler::parseRoute(std::string_view method,
std::string_view url) {
// Strip query parameters if present
size_t query_pos = url.find('?');
if (query_pos != std::string_view::npos) {
url = url.substr(0, query_pos);
}
// Route based on method and path
if (method == "GET") {
if (url == "/v1/version")
return HttpRoute::GET_version;
if (url == "/v1/subscribe")
return HttpRoute::GET_subscribe;
if (url.starts_with("/v1/status"))
return HttpRoute::GET_status;
if (url.starts_with("/v1/retention")) {
// Check if it's a specific retention policy or list all
return HttpRoute::GET_retention;
}
if (url == "/metrics")
return HttpRoute::GET_metrics;
if (url == "/ok")
return HttpRoute::GET_ok;
} else if (method == "POST") {
if (url == "/v1/commit")
return HttpRoute::POST_commit;
} else if (method == "PUT") {
if (url.starts_with("/v1/retention/"))
return HttpRoute::PUT_retention;
} else if (method == "DELETE") {
if (url.starts_with("/v1/retention/"))
return HttpRoute::DELETE_retention;
}
return HttpRoute::NotFound;
}
// Route handlers (basic implementations)
void HttpHandler::handleGetVersion(Connection &conn,
const HttpConnectionState &state) {
sendJsonResponse(
conn, 200,
R"({"version":"0.0.1","leader":"node-1","committed_version":42})",
state.connection_close);
void HttpHandler::handle_get_version(Connection &, HttpRequestState &) {
version_counter.inc();
// Sent to commit pipeline
}
void HttpHandler::handlePostCommit(Connection &conn,
const HttpConnectionState &state) {
// TODO: Parse commit request from state.body and process
sendJsonResponse(
conn, 200,
R"({"request_id":"example","status":"committed","version":43})",
state.connection_close);
void HttpHandler::handle_post_commit(Connection &conn,
HttpRequestState &state) {
commit_counter.inc();
auto *ctx = state.response_context;
assert(ctx);
// Check if streaming parse was successful
if (!state.commit_request || !state.parsing_commit) {
auto json_response = R"({"error":"Parse failed"})";
auto http_response =
format_json_response(400, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
return;
}
const CommitRequest &commit_request = *state.commit_request;
// Perform basic validation that doesn't require serialization (done on I/O
// threads)
bool valid = true;
std::string_view error_msg;
// Check that we have at least one operation
if (commit_request.operations().empty()) {
valid = false;
error_msg = "Commit request must contain at least one operation";
}
// Check leader_id is not empty
if (valid && commit_request.leader_id().empty()) {
valid = false;
error_msg = "Commit request must specify a leader_id";
}
// Check operations are well-formed
if (valid) {
for (const auto &op : commit_request.operations()) {
if (op.param1.empty()) {
valid = false;
error_msg = "Operation key cannot be empty";
break;
}
if (op.type == Operation::Type::Write && op.param2.empty()) {
valid = false;
error_msg = "Write operation value cannot be empty";
break;
}
}
}
if (!valid) {
auto json_response =
format(state.arena, R"({"error":"%.*s"})",
static_cast<int>(error_msg.size()), error_msg.data());
auto http_response =
format_json_response(400, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
return;
}
// Basic validation passed - mark for 4-stage pipeline processing
state.basic_validation_passed = true;
// Response will be sent after 4-stage pipeline processing is complete
}
void HttpHandler::handleGetSubscribe(Connection &conn,
const HttpConnectionState &state) {
void HttpHandler::handle_get_subscribe(Connection &conn,
HttpRequestState &state) {
auto *ctx = state.response_context;
assert(ctx);
// TODO: Implement subscription streaming
sendJsonResponse(
conn, 200,
R"({"message":"Subscription endpoint - streaming not yet implemented"})",
state.connection_close);
auto json_response =
R"({"message":"Subscription endpoint - streaming not yet implemented"})";
auto http_response =
format_json_response(200, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
}
void HttpHandler::handleGetStatus(Connection &conn,
const HttpConnectionState &state) {
// TODO: Extract request_id from URL and check status
sendJsonResponse(
conn, 200,
R"({"request_id":"example","status":"committed","version":43})",
state.connection_close);
void HttpHandler::handle_get_status(Connection &conn, HttpRequestState &state,
const RouteMatch &route_match) {
status_counter.inc();
auto *ctx = state.response_context;
assert(ctx);
// Status requests are processed through the pipeline
// Response will be generated in the sequence stage
// This handler extracts request_id from query parameters and prepares for
// pipeline processing
const auto &request_id =
route_match.params[static_cast<int>(ApiParameterKey::RequestId)];
if (!request_id) {
auto json_response =
R"({"error":"Missing required query parameter: request_id"})";
auto http_response =
format_json_response(400, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
// Add directly to response queue with proper sequencing
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
return;
}
if (request_id->empty()) {
auto json_response = R"({"error":"Empty request_id parameter"})";
auto http_response =
format_json_response(400, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
// Add directly to response queue with proper sequencing
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
return;
}
// Store the request_id in the state for the pipeline
state.status_request_id = *request_id;
// Ready for pipeline processing
}
void HttpHandler::handlePutRetention(Connection &conn,
const HttpConnectionState &state) {
void HttpHandler::handle_put_retention(Connection &conn,
HttpRequestState &state,
const RouteMatch &) {
auto *ctx = state.response_context;
assert(ctx);
// TODO: Parse retention policy from body and store
sendJsonResponse(conn, 200, R"({"policy_id":"example","status":"created"})",
state.connection_close);
auto json_response = R"({"policy_id":"example","status":"created"})";
auto http_response =
format_json_response(200, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
// Send through reorder queue and preprocessing to maintain proper ordering
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
}
void HttpHandler::handleGetRetention(Connection &conn,
const HttpConnectionState &state) {
void HttpHandler::handle_get_retention(Connection &conn,
HttpRequestState &state,
const RouteMatch &) {
auto *ctx = state.response_context;
assert(ctx);
// TODO: Extract policy_id from URL or return all policies
sendJsonResponse(conn, 200, R"({"policies":[]})", state.connection_close);
auto json_response = R"({"policies":[]})";
auto http_response =
format_json_response(200, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
// Send through reorder queue and preprocessing to maintain proper ordering
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
}
void HttpHandler::handleDeleteRetention(Connection &conn,
const HttpConnectionState &state) {
void HttpHandler::handle_delete_retention(Connection &conn,
HttpRequestState &state,
const RouteMatch &) {
auto *ctx = state.response_context;
assert(ctx);
// TODO: Extract policy_id from URL and delete
sendJsonResponse(conn, 200, R"({"policy_id":"example","status":"deleted"})",
state.connection_close);
auto json_response = R"({"policy_id":"example","status":"deleted"})";
auto http_response =
format_json_response(200, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
// Send through reorder queue and preprocessing to maintain proper ordering
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
}
void HttpHandler::handleGetMetrics(Connection &conn,
const HttpConnectionState &state) {
// TODO: Implement metrics collection and formatting
sendResponse(conn, 200, "text/plain",
"# WeaselDB metrics\nweaseldb_requests_total 0\n",
state.connection_close);
void HttpHandler::handle_get_metrics(Connection &conn,
HttpRequestState &state) {
auto *ctx = state.response_context;
assert(ctx);
metrics_counter.inc();
auto metrics_span = metric::render(state.arena);
// Calculate total size for the response body
size_t total_size = 0;
for (const auto &sv : metrics_span) {
total_size += sv.size();
}
// Build HTTP response with metrics data
auto result =
state.arena.allocate_span<std::string_view>(metrics_span.size() + 1);
auto out = result.begin();
// Build HTTP headers
std::string_view headers;
if (ctx->connection_close) {
headers = static_format(
state.arena, "HTTP/1.1 200 OK\r\n",
"Content-Type: text/plain; version=0.0.4\r\n",
"Content-Length: ", static_cast<uint64_t>(total_size), "\r\n",
"X-Response-ID: ", static_cast<int64_t>(ctx->http_request_id), "\r\n",
"Connection: close\r\n", "\r\n");
} else {
headers = static_format(
state.arena, "HTTP/1.1 200 OK\r\n",
"Content-Type: text/plain; version=0.0.4\r\n",
"Content-Length: ", static_cast<uint64_t>(total_size), "\r\n",
"X-Response-ID: ", static_cast<int64_t>(ctx->http_request_id), "\r\n",
"Connection: keep-alive\r\n", "\r\n");
}
*out++ = headers;
for (auto sv : metrics_span) {
*out++ = sv;
}
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, result, std::move(state.arena));
}
void HttpHandler::handleGetOk(Connection &conn,
const HttpConnectionState &state) {
TRACE_EVENT("http", "GET /ok", perfetto::Flow::Global(state.request_id));
void HttpHandler::handle_get_ok(Connection &, HttpRequestState &) {
ok_counter.inc();
sendResponse(conn, 200, "text/plain", "OK", state.connection_close);
// Health check requests are processed through the pipeline
// Response will be generated in the release stage after pipeline processing
}
void HttpHandler::handleNotFound(Connection &conn,
const HttpConnectionState &state) {
sendErrorResponse(conn, 404, "Not found", state.connection_close);
void HttpHandler::handle_not_found(Connection &conn, HttpRequestState &state) {
auto *ctx = state.response_context;
assert(ctx);
not_found_counter.inc();
auto json_response = R"({"error":"Not found"})";
auto http_response =
format_json_response(404, json_response, state.arena,
ctx->http_request_id, ctx->connection_close);
auto *conn_state = static_cast<HttpConnectionState *>(conn.user_data);
conn_state->send_ordered_response(conn, ctx, http_response,
std::move(state.arena));
}
// HTTP utility methods
void HttpHandler::sendResponse(Connection &conn, int status_code,
std::string_view content_type,
std::string_view body, bool close_connection) {
[[maybe_unused]] ArenaAllocator &arena = conn.get_arena();
// Build HTTP response using arena
std::string response;
response.reserve(256 + body.size());
response += "HTTP/1.1 ";
response += std::to_string(status_code);
response += " ";
std::span<std::string_view>
HttpHandler::format_response(int status_code, std::string_view content_type,
std::string_view body, Arena &response_arena,
int64_t http_request_id, bool close_connection) {
// Status text
std::string_view status_text;
switch (status_code) {
case 200:
response += "OK";
status_text = "OK";
break;
case 400:
response += "Bad Request";
status_text = "Bad Request";
break;
case 404:
response += "Not Found";
status_text = "Not Found";
break;
case 500:
response += "Internal Server Error";
status_text = "Internal Server Error";
break;
default:
response += "Unknown";
status_text = "Unknown";
break;
}
auto *state = static_cast<HttpConnectionState *>(conn.user_data);
const char *connection_header = close_connection ? "close" : "keep-alive";
response += "\r\n";
response += "Content-Type: ";
response += content_type;
response += "\r\n";
response += "Content-Length: ";
response += std::to_string(body.size());
response += "\r\n";
response += "X-Response-ID: ";
response += std::to_string(state->request_id);
response += "\r\n";
auto response = response_arena.allocate_span<std::string_view>(1);
if (close_connection) {
response += "Connection: close\r\n";
conn.close_after_send(); // Signal connection should be closed after sending
} else {
response += "Connection: keep-alive\r\n";
}
response[0] =
format(response_arena,
"HTTP/1.1 %d %.*s\r\n"
"Content-Type: %.*s\r\n"
"Content-Length: %zu\r\n"
"X-Response-ID: %ld\r\n"
"Connection: %s\r\n"
"\r\n%.*s",
status_code, static_cast<int>(status_text.size()),
status_text.data(), static_cast<int>(content_type.size()),
content_type.data(), body.size(), http_request_id,
connection_header, static_cast<int>(body.size()), body.data());
response += "\r\n";
response += body;
conn.append_message(response);
return response;
}
void HttpHandler::sendJsonResponse(Connection &conn, int status_code,
std::string_view json,
bool close_connection) {
sendResponse(conn, status_code, "application/json", json, close_connection);
}
void HttpHandler::sendErrorResponse(Connection &conn, int status_code,
std::string_view message,
bool close_connection) {
[[maybe_unused]] ArenaAllocator &arena = conn.get_arena();
std::string json = R"({"error":")";
json += message;
json += R"("})";
sendJsonResponse(conn, status_code, json, close_connection);
std::span<std::string_view> HttpHandler::format_json_response(
int status_code, std::string_view json, Arena &response_arena,
int64_t http_request_id, bool close_connection) {
return format_response(status_code, "application/json", json, response_arena,
http_request_id, close_connection);
}
// llhttp callbacks
int HttpHandler::onUrl(llhttp_t *parser, const char *at, size_t length) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
// Store URL in arena (simplified - would need to accumulate for streaming)
state->url = std::string_view(at, length);
auto *state = static_cast<HttpRequestState *>(parser->data);
// Accumulate URL data
state->url.append(at, length);
return 0;
}
int HttpHandler::onHeaderField(llhttp_t *parser, const char *at,
size_t length) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
auto *state = static_cast<HttpRequestState *>(parser->data);
// Accumulate header field data
state->current_header_field_buf.append(at, length);
return 0;
}
int HttpHandler::onHeaderFieldComplete(llhttp_t *parser) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
auto *state = static_cast<HttpRequestState *>(parser->data);
state->header_field_complete = true;
return 0;
}
int HttpHandler::onHeaderValue(llhttp_t *parser, const char *at,
size_t length) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
auto *state = static_cast<HttpRequestState *>(parser->data);
// Accumulate header value data
state->current_header_value_buf.append(at, length);
return 0;
}
int HttpHandler::onHeaderValueComplete(llhttp_t *parser) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
auto *state = static_cast<HttpRequestState *>(parser->data);
if (!state->header_field_complete) {
// Field is not complete yet, wait
@@ -380,7 +678,7 @@ int HttpHandler::onHeaderValueComplete(llhttp_t *parser) {
id = id * 10 + (c - '0');
}
}
state->request_id = id;
state->http_request_id = id;
}
// Clear buffers for next header
@@ -392,7 +690,7 @@ int HttpHandler::onHeaderValueComplete(llhttp_t *parser) {
}
int HttpHandler::onHeadersComplete(llhttp_t *parser) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
auto *state = static_cast<HttpRequestState *>(parser->data);
state->headers_complete = true;
// Get HTTP method
@@ -400,19 +698,41 @@ int HttpHandler::onHeadersComplete(llhttp_t *parser) {
llhttp_method_name(static_cast<llhttp_method_t>(parser->method));
state->method = std::string_view(method_str);
// Check if this looks like a POST to /v1/commit to initialize streaming
// parser
if (state->method == "POST" && state->url.find("/v1/commit") == 0) {
// Initialize streaming commit request parsing
state->commit_parser = state->arena.construct<JsonCommitRequestParser>();
state->commit_request = state->arena.construct<CommitRequest>();
state->parsing_commit =
state->commit_parser->begin_streaming_parse(*state->commit_request);
if (!state->parsing_commit) {
return -1; // Signal parsing error to llhttp
}
}
return 0;
}
int HttpHandler::onBody(llhttp_t *parser, const char *at, size_t length) {
[[maybe_unused]] auto *state =
static_cast<HttpConnectionState *>(parser->data);
(void)at;
(void)length;
auto *state = static_cast<HttpRequestState *>(parser->data);
if (state->parsing_commit && state->commit_parser) {
// Stream data to commit request parser
auto status =
state->commit_parser->parse_chunk(const_cast<char *>(at), length);
if (status == CommitRequestParser::ParseStatus::Error) {
return -1; // Signal parsing error to llhttp
}
}
return 0;
}
int HttpHandler::onMessageComplete(llhttp_t *parser) {
auto *state = static_cast<HttpConnectionState *>(parser->data);
auto *state = static_cast<HttpRequestState *>(parser->data);
state->message_complete = true;
return 0;
return HPE_PAUSED;
}
+118 -125
View File
@@ -1,46 +1,52 @@
#pragma once
#include <map>
#include <memory>
#include <string_view>
#include <thread>
#include <llhttp.h>
#include "api_url_parser.hpp"
#include "arena.hpp"
#include "commit_pipeline.hpp"
#include "config.hpp"
#include "connection.hpp"
#include "connection_handler.hpp"
#include "loop_iterations.h"
#include "perfetto_categories.hpp"
#include "server.hpp"
#include "thread_pipeline.hpp"
// Forward declarations
struct CommitRequest;
struct JsonCommitRequestParser;
struct RouteMatch;
/**
* HTTP routes supported by WeaselDB server.
* Using enum for efficient switch-based routing.
* HTTP-specific response context stored in pipeline entries.
* Arena-allocated and identified by a ProtocolHandle (sequence_id).
* Also owns the response data once it becomes ready.
*/
enum class HttpRoute {
GET_version,
POST_commit,
GET_subscribe,
GET_status,
PUT_retention,
GET_retention,
DELETE_retention,
GET_metrics,
GET_ok,
NotFound
struct HttpResponseContext {
int64_t sequence_id; // For response ordering in pipelining
int64_t http_request_id; // For X-Response-ID header
bool connection_close; // Whether to close connection after response
// Response payload; populated when the response is ready.
bool ready = false;
std::span<std::string_view> data;
Arena arena;
};
/**
* HTTP connection state stored in Connection::user_data.
* Manages llhttp parser state and request data.
*/
struct HttpConnectionState {
llhttp_t parser;
llhttp_settings_t settings;
struct HttpRequestState {
Arena arena{16 << 10}; // Request-scoped arena for parsing state
// Current request data (arena-allocated)
std::string_view method;
std::string_view url;
using ArenaString =
std::basic_string<char, std::char_traits<char>, ArenaStlAllocator<char>>;
ArenaString url;
// Parse state
bool headers_complete = false;
@@ -48,15 +54,65 @@ struct HttpConnectionState {
bool connection_close = false; // Client requested connection close
HttpRoute route = HttpRoute::NotFound;
// Status request data
std::string_view
status_request_id; // Request ID extracted from /v1/status/{id} URL
// Header accumulation buffers (arena-allocated)
using ArenaString =
std::basic_string<char, std::char_traits<char>, ArenaStlAllocator<char>>;
ArenaString current_header_field_buf;
ArenaString current_header_value_buf;
bool header_field_complete = false;
int64_t request_id = 0; // X-Request-Id header value
int64_t http_request_id =
0; // X-Request-Id header value (for tracing/logging)
int64_t sequence_id = 0; // Assigned for response ordering in pipelining
explicit HttpConnectionState(ArenaAllocator &arena);
// HTTP response context for this request (set during batch processing).
// Non-owning: the context is owned by HttpConnectionState::contexts_.
HttpResponseContext *response_context = nullptr;
// Streaming parser for POST requests
Arena::Ptr<JsonCommitRequestParser> commit_parser;
Arena::Ptr<CommitRequest> commit_request;
bool parsing_commit = false;
bool basic_validation_passed =
false; // Set to true if basic validation passes
HttpRequestState();
};
struct HttpConnectionState {
llhttp_t parser;
llhttp_settings_t settings;
HttpRequestState pending;
std::deque<HttpRequestState> queue;
int64_t get_next_sequence_id() { return next_sequence_id++; }
HttpConnectionState();
~HttpConnectionState();
// Register an HttpResponseContext so the pipeline can resolve it by handle.
void register_response_context(int64_t sequence_id,
std::unique_ptr<HttpResponseContext> ctx);
// Resolve a ProtocolHandle back to its HttpResponseContext.
HttpResponseContext *resolve_response_context(ProtocolHandle handle);
// Mark a context's response data as ready and try to send in-order.
// The context may already be registered in contexts_; if so, it is moved out
// and populated. A transient context pointer (e.g., for parse errors) works
// too.
void send_ordered_response(Connection &conn, HttpResponseContext *ctx,
std::span<std::string_view> http_response,
Arena arena);
private:
// All registered response contexts keyed by sequence_id.
// A context stays here until its response has been dispatched.
std::map<int64_t, std::unique_ptr<HttpResponseContext>> contexts_;
int64_t next_sequence_to_send = 0;
int64_t next_sequence_id = 0;
};
/**
@@ -64,82 +120,16 @@ struct HttpConnectionState {
* Supports the WeaselDB REST API endpoints with enum-based routing.
*/
struct HttpHandler : ConnectionHandler {
HttpHandler() {
finalStageThreads.emplace_back([this]() {
pthread_setname_np(pthread_self(), "stage-1-0");
for (;;) {
auto guard = pipeline.acquire<1, 0>();
for (auto it = guard.batch.begin(); it != guard.batch.end(); ++it) {
if ((it.index() % 2) == 0) { // Thread 0 handles even indices
auto &c = *it;
if (!c) {
return;
}
auto *state = static_cast<HttpConnectionState *>(c->user_data);
TRACE_EVENT("http", "release",
perfetto::Flow::Global(state->request_id));
Server::release_back_to_server(std::move(c));
}
}
}
});
finalStageThreads.emplace_back([this]() {
pthread_setname_np(pthread_self(), "stage-1-1");
for (;;) {
auto guard = pipeline.acquire<1, 1>();
for (auto it = guard.batch.begin(); it != guard.batch.end(); ++it) {
if ((it.index() % 2) == 1) { // Thread 1 handles odd indices
auto &c = *it;
if (!c) {
return;
}
auto *state = static_cast<HttpConnectionState *>(c->user_data);
TRACE_EVENT("http", "release",
perfetto::Flow::Global(state->request_id));
Server::release_back_to_server(std::move(c));
}
}
}
});
stage0Thread = std::thread{[this]() {
pthread_setname_np(pthread_self(), "stage-0");
int nulls = 0;
for (;;) {
auto guard = pipeline.acquire<0, 0>(1);
for (auto &c : guard.batch) {
nulls += !c;
if (nulls == 2) {
return;
}
for (volatile int i = 0; i < loopIterations; i = i + 1)
;
}
}
}};
}
~HttpHandler() {
{
auto guard = pipeline.push(2, true);
for (auto &c : guard.batch) {
c = {};
}
}
stage0Thread.join();
for (auto &thread : finalStageThreads) {
thread.join();
}
}
explicit HttpHandler(const weaseldb::Config &config)
: config_(config), commit_pipeline_(config) {}
void on_connection_established(Connection &conn) override;
void on_connection_closed(Connection &conn) override;
void on_data_arrived(std::string_view data,
std::unique_ptr<Connection> &conn_ptr) override;
void on_write_buffer_drained(std::unique_ptr<Connection> &conn_ptr) override;
void on_batch_complete(
std::span<std::unique_ptr<Connection>> /*batch*/) override;
// Route parsing (public for testing)
static HttpRoute parseRoute(std::string_view method, std::string_view url);
void on_data_arrived(std::string_view data, Connection &conn) override;
void
on_preprocess_writes(Connection &conn,
std::span<PendingResponse> pending_responses) override;
void on_batch_complete(std::span<Connection *const> batch) override;
// llhttp callbacks (public for HttpConnectionState access)
static int onUrl(llhttp_t *parser, const char *at, size_t length);
@@ -152,34 +142,37 @@ struct HttpHandler : ConnectionHandler {
static int onMessageComplete(llhttp_t *parser);
private:
static constexpr int lg_size = 16;
StaticThreadPipeline<std::unique_ptr<Connection>,
WaitStrategy::WaitIfUpstreamIdle, 1, 2>
pipeline{lg_size};
std::thread stage0Thread;
std::vector<std::thread> finalStageThreads;
// Configuration reference
const weaseldb::Config &config_;
// Commit processing pipeline
CommitPipeline commit_pipeline_;
// Route handlers
void handleGetVersion(Connection &conn, const HttpConnectionState &state);
void handlePostCommit(Connection &conn, const HttpConnectionState &state);
void handleGetSubscribe(Connection &conn, const HttpConnectionState &state);
void handleGetStatus(Connection &conn, const HttpConnectionState &state);
void handlePutRetention(Connection &conn, const HttpConnectionState &state);
void handleGetRetention(Connection &conn, const HttpConnectionState &state);
void handleDeleteRetention(Connection &conn,
const HttpConnectionState &state);
void handleGetMetrics(Connection &conn, const HttpConnectionState &state);
void handleGetOk(Connection &conn, const HttpConnectionState &state);
void handleNotFound(Connection &conn, const HttpConnectionState &state);
void handle_get_version(Connection &conn, HttpRequestState &state);
void handle_post_commit(Connection &conn, HttpRequestState &state);
void handle_get_subscribe(Connection &conn, HttpRequestState &state);
void handle_get_status(Connection &conn, HttpRequestState &state,
const RouteMatch &route_match);
void handle_put_retention(Connection &conn, HttpRequestState &state,
const RouteMatch &route_match);
void handle_get_retention(Connection &conn, HttpRequestState &state,
const RouteMatch &route_match);
void handle_delete_retention(Connection &conn, HttpRequestState &state,
const RouteMatch &route_match);
void handle_get_metrics(Connection &conn, HttpRequestState &state);
void handle_get_ok(Connection &conn, HttpRequestState &state);
void handle_not_found(Connection &conn, HttpRequestState &state);
// HTTP utilities
static void sendResponse(Connection &conn, int status_code,
std::string_view content_type, std::string_view body,
bool close_connection = false);
static void sendJsonResponse(Connection &conn, int status_code,
std::string_view json,
bool close_connection = false);
static void sendErrorResponse(Connection &conn, int status_code,
std::string_view message,
bool close_connection = false);
// Helper functions for formatting responses without sending
static std::span<std::string_view>
format_response(int status_code, std::string_view content_type,
std::string_view body, Arena &response_arena,
int64_t http_request_id, bool close_connection);
static std::span<std::string_view>
format_json_response(int status_code, std::string_view json,
Arena &response_arena, int64_t http_request_id,
bool close_connection);
};
+3 -3
View File
@@ -3,7 +3,7 @@
#include <memory>
#include <simdutf.h>
#include <weaseljson/weaseljson.h>
#include <weaseljson.h>
#include "commit_request_parser.hpp"
#include "json_token_enum.hpp"
@@ -70,7 +70,7 @@ private:
ArenaString operation_type;
// Constructor to initialize arena-allocated containers
explicit ParserContext(ArenaAllocator *arena)
explicit ParserContext(Arena *arena)
: current_key(ArenaStlAllocator<char>(arena)),
current_string(ArenaStlAllocator<char>(arena)),
current_number(ArenaStlAllocator<char>(arena)),
@@ -79,7 +79,7 @@ private:
has_read_version_been_set = false;
}
void attach_arena(ArenaAllocator *arena) {
void attach_arena(Arena *arena) {
current_key = ArenaString{ArenaStlAllocator<char>(arena)};
current_string = ArenaString{ArenaStlAllocator<char>(arena)};
current_number = ArenaString{ArenaStlAllocator<char>(arena)};
-3
View File
@@ -1,3 +0,0 @@
#pragma once
constexpr int loopIterations = 1725;
+77 -51
View File
@@ -1,10 +1,11 @@
#include "config.hpp"
#include "connection.hpp"
#include "connection_handler.hpp"
#include "http_handler.hpp"
#include "metric.hpp"
#include "perfetto_categories.hpp"
#include "process_collector.hpp"
#include "reference.hpp"
#include "server.hpp"
#include <atomic>
#include <csignal>
#include <cstring>
#include <fcntl.h>
@@ -29,12 +30,7 @@ void signal_handler(int sig) {
}
}
std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
std::vector<int> listen_fds;
// Check if unix socket path is specified
if (!config.server.unix_socket_path.empty()) {
// Create unix socket
int create_unix_socket(const std::string &path) {
int sfd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sfd == -1) {
perror("socket");
@@ -42,19 +38,18 @@ std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
}
// Remove existing socket file if it exists
unlink(config.server.unix_socket_path.c_str());
unlink(path.c_str());
struct sockaddr_un addr;
std::memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
if (config.server.unix_socket_path.length() >= sizeof(addr.sun_path)) {
std::fprintf(stderr, "Unix socket path too long\n");
if (path.length() >= sizeof(addr.sun_path)) {
std::fprintf(stderr, "Unix socket path too long: %s\n", path.c_str());
std::abort();
}
std::strncpy(addr.sun_path, config.server.unix_socket_path.c_str(),
sizeof(addr.sun_path) - 1);
std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1);
if (bind(sfd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
perror("bind");
@@ -66,11 +61,10 @@ std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
std::abort();
}
listen_fds.push_back(sfd);
return listen_fds;
}
return sfd;
}
// TCP socket creation
int create_tcp_socket(const std::string &address, int port) {
struct addrinfo hints;
struct addrinfo *result, *rp;
int s;
@@ -84,8 +78,8 @@ std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
hints.ai_addr = nullptr;
hints.ai_next = nullptr;
s = getaddrinfo(config.server.bind_address.c_str(),
std::to_string(config.server.port).c_str(), &hints, &result);
s = getaddrinfo(address.c_str(), std::to_string(port).c_str(), &hints,
&result);
if (s != 0) {
std::fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
std::abort();
@@ -94,18 +88,13 @@ std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
int sfd = -1;
for (rp = result; rp != nullptr; rp = rp->ai_next) {
sfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (sfd == -1) {
if (sfd == -1)
continue;
}
int val = 1;
if (setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val)) == -1) {
perror("setsockopt SO_REUSEADDR");
int e = close(sfd);
if (e == -1 && errno != EINTR) {
perror("close sfd (SO_REUSEADDR failed)");
std::abort();
}
close(sfd);
continue;
}
@@ -113,40 +102,56 @@ std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
if (rp->ai_family == AF_INET || rp->ai_family == AF_INET6) {
if (setsockopt(sfd, IPPROTO_TCP, TCP_NODELAY, &val, sizeof(val)) == -1) {
perror("setsockopt TCP_NODELAY");
int e = close(sfd);
if (e == -1 && errno != EINTR) {
perror("close sfd (TCP_NODELAY failed)");
std::abort();
}
close(sfd);
continue;
}
}
if (bind(sfd, rp->ai_addr, rp->ai_addrlen) == 0) {
if (listen(sfd, SOMAXCONN) == -1) {
perror("listen");
close(sfd);
freeaddrinfo(result);
std::abort();
}
break; /* Success */
}
int e = close(sfd);
if (e == -1 && errno != EINTR) {
perror("close sfd (bind failed)");
std::abort();
}
close(sfd);
sfd = -1;
}
freeaddrinfo(result);
if (rp == nullptr || sfd == -1) {
std::fprintf(stderr, "Could not bind to any address\n");
if (sfd == -1) {
std::fprintf(stderr, "Could not bind to %s:%d\n", address.c_str(), port);
std::abort();
}
if (listen(sfd, SOMAXCONN) == -1) {
perror("listen");
return sfd;
}
std::vector<int> create_listen_sockets(const weaseldb::Config &config) {
std::vector<int> listen_fds;
for (const auto &iface : config.server.interfaces) {
int fd;
if (iface.type == weaseldb::ListenInterface::Type::TCP) {
fd = create_tcp_socket(iface.address, iface.port);
std::cout << "Listening on TCP " << iface.address << ":" << iface.port
<< std::endl;
} else {
fd = create_unix_socket(iface.path);
std::cout << "Listening on Unix socket " << iface.path << std::endl;
}
listen_fds.push_back(fd);
}
if (listen_fds.empty()) {
std::fprintf(stderr, "No interfaces configured\n");
std::abort();
}
listen_fds.push_back(sfd);
return listen_fds;
}
@@ -176,6 +181,9 @@ int main(int argc, char *argv[]) {
perfetto::TrackEvent::Register();
#endif
// Register the process collector for default metrics.
metric::register_collector(make_ref<ProcessCollector>());
std::string config_file = "config.toml";
// Parse command line arguments
@@ -215,19 +223,18 @@ int main(int argc, char *argv[]) {
}
std::cout << "Configuration loaded successfully:" << std::endl;
if (!config->server.unix_socket_path.empty()) {
std::cout << "Unix socket path: " << config->server.unix_socket_path
<< std::endl;
std::cout << "Interfaces: " << config->server.interfaces.size() << std::endl;
for (const auto &iface : config->server.interfaces) {
if (iface.type == weaseldb::ListenInterface::Type::TCP) {
std::cout << " TCP: " << iface.address << ":" << iface.port << std::endl;
} else {
std::cout << "Server bind address: " << config->server.bind_address
<< std::endl;
std::cout << "Server port: " << config->server.port << std::endl;
std::cout << " Unix socket: " << iface.path << std::endl;
}
}
std::cout << "Max request size: " << config->server.max_request_size_bytes
<< " bytes" << std::endl;
std::cout << "I/O threads: " << config->server.io_threads << std::endl;
std::cout << "Epoll instances: " << config->server.epoll_instances
<< std::endl;
std::cout << "Epoll instances: " << config->server.io_threads << std::endl;
std::cout << "Event batch size: " << config->server.event_batch_size
<< std::endl;
std::cout << "Max connections: " << config->server.max_connections
@@ -239,23 +246,42 @@ int main(int argc, char *argv[]) {
std::cout << "Request ID retention: "
<< config->commit.request_id_retention_hours.count() << " hours"
<< std::endl;
// Print pipeline configuration
std::string wait_strategy_str;
switch (config->commit.pipeline_wait_strategy) {
case WaitStrategy::WaitIfStageEmpty:
wait_strategy_str = "WaitIfStageEmpty";
break;
case WaitStrategy::WaitIfUpstreamIdle:
wait_strategy_str = "WaitIfUpstreamIdle";
break;
case WaitStrategy::Never:
wait_strategy_str = "Never";
break;
}
std::cout << "Pipeline wait strategy: " << wait_strategy_str << std::endl;
std::cout << "Pipeline release threads: "
<< config->commit.pipeline_release_threads << std::endl;
std::cout << "Subscription buffer size: "
<< config->subscription.max_buffer_size_bytes << " bytes"
<< std::endl;
std::cout << "Keepalive interval: "
<< config->subscription.keepalive_interval.count() << " seconds"
<< std::endl;
std::cout << "Health check resolve iterations: "
<< config->benchmark.ok_resolve_iterations << std::endl;
// Create listen sockets
std::vector<int> listen_fds = create_listen_sockets(*config);
// Create handler and server
HttpHandler http_handler;
HttpHandler http_handler(*config);
auto server = Server::create(*config, http_handler, listen_fds);
g_server = server.get();
// Setup signal handling
std::signal(SIGPIPE, SIG_IGN);
std::signal(SIGTERM, signal_handler);
std::signal(SIGINT, signal_handler);
+1906
View File
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
#pragma once
// WeaselDB Metrics System
//
// High-performance metrics collection with Prometheus-compatible output.
//
// DESIGN PRINCIPLES:
// - Single-writer semantics: Each metric instance bound to creating thread
// - Lock-free operations using atomic<uint64_t> storage for doubles
// - Full IEEE 754 double precision preservation via bit reinterpretation
// - Single global registry: All metrics registered in one global namespace
//
// CRITICAL THREAD SAFETY CONSTRAINT:
// Each metric instance has exactly ONE writer thread (the creating thread).
// It is undefined behavior to call inc()/dec()/set()/observe() from a different
// thread.
//
// REGISTRY MODEL:
// This implementation uses a single global registry for all metrics, unlike
// typical Prometheus client libraries that support multiple registries.
// This design choice prioritizes simplicity and performance over flexibility.
//
// PERFORMANCE NOTE:
// Family registration operations (create_counter/gauge/histogram), metric
// instance creation (.create()), and render() use a global mutex for thread
// safety. Registration operations should be performed during application
// initialization, not in performance-critical paths. Metric update operations
// (inc/dec/set/observe) are designed for high-frequency use and do not contend
// on the global mutex.
//
// METRIC LIFECYCLE:
// Metrics are created once and persist for the application lifetime. There is
// no unregistration mechanism - this prevents accidental metric loss and
// simplifies the implementation.
//
// USAGE:
// auto counter_family = metric::create_counter("requests_total", "Total
// requests"); auto counter = counter_family.create({{"method", "GET"}}); //
// Bound to this thread counter.inc(1.0); // ONLY call from creating thread
//
// auto histogram_family = metric::create_histogram("latency", "Request
// latency", {0.1, 0.5, 1.0}); auto histogram =
// histogram_family.create({{"endpoint", "/api"}}); // Bound to this thread
// histogram.observe(0.25); // ONLY call from creating thread
#include <functional>
#include <initializer_list>
#include <span>
#include <type_traits>
#include <vector>
#include "arena.hpp"
#include "reference.hpp"
namespace metric {
// Forward declarations
template <typename T> struct Family;
// Callback function type for dynamic metric values
// Called during render() to get current metric value
// THREAD SAFETY: May be called from arbitrary thread, but serialized by
// render() mutex - no need to be thread-safe internally
template <typename T> using MetricCallback = std::function<double()>;
// Counter: A metric value that only increases.
//
// THREAD SAFETY RULES:
// 1. Do not call inc() on the same Counter object from multiple threads.
// Each object must have only one writer thread.
// 2. To use Counters concurrently, each thread must create its own Counter
// object.
// 3. When rendered, the values of all Counter objects with the same labels
// are summed together into a single total.
struct Counter {
void inc(double = 1.0); // Increment counter (must be >= 0, never blocks)
private:
Counter();
friend struct Metric;
template <class> friend struct Family;
struct State;
State *p;
};
// Gauge: A metric value that can be set, increased, or decreased.
//
// THREAD SAFETY RULES:
// 1. Do not call inc(), dec(), or set() on the same Gauge object from
// multiple threads. Each object must have only one writer thread.
// 2. To use Gauges concurrently, each thread must create its own Gauge object.
// 3. If multiple Gauge objects are created with the same labels, their
// operations are combined. For example, increments from different objects
// are cumulative.
// 4. For independent gauges, create them with unique labels.
struct Gauge {
void inc(double = 1.0); // (never blocks)
void dec(double = 1.0); // (never blocks)
void set(double); // (never blocks)
private:
Gauge();
friend struct Metric;
template <class> friend struct Family;
struct State;
State *p;
};
// Histogram: A metric that samples observations into buckets.
//
// THREAD SAFETY RULES:
// 1. Do not call observe() on the same Histogram object from multiple
// threads. Each object must have only one writer thread.
// 2. To use Histograms concurrently, each thread must create its own
// Histogram object.
// 3. When rendered, the observations from all Histogram objects with the
// same labels are combined into a single histogram.
struct Histogram {
void
observe(double); // Record observation in appropriate bucket (never blocks)
private:
Histogram();
friend struct Metric;
template <class> friend struct Family;
struct State;
State *p;
};
// Family: Factory for creating metric instances with different label
// combinations Each family represents one metric name with varying labels
template <class T> struct Family {
static_assert(std::is_same_v<T, Counter> || std::is_same_v<T, Gauge> ||
std::is_same_v<T, Histogram>);
// Create metric instance with specific labels.
// For performance, it is recommended to create instances once and cache them
// for reuse, rather than calling .create() repeatedly in
// performance-critical paths.
//
// Labels are sorted by key for Prometheus compatibility.
// ERROR: Will abort if labels already registered via register_callback().
// OK: Multiple calls with same labels return same instance (idempotent).
T create(std::initializer_list<std::pair<std::string_view, std::string_view>>
labels) {
return create(
std::span<const std::pair<std::string_view, std::string_view>>(
labels.begin(), labels.end()));
}
T create(
std::span<const std::pair<std::string_view, std::string_view>> labels);
// Register callback-based metric (Counter and Gauge only)
// Validates that label set isn't already taken
void register_callback(
std::initializer_list<std::pair<std::string_view, std::string_view>>
labels,
MetricCallback<T> callback) {
register_callback(
std::span<const std::pair<std::string_view, std::string_view>>(
labels.begin(), labels.end()),
callback);
}
void register_callback(
std::span<const std::pair<std::string_view, std::string_view>> labels,
MetricCallback<T> callback);
private:
Family();
friend struct Metric;
friend Family<Counter> create_counter(std::string_view, std::string_view);
friend Family<Gauge> create_gauge(std::string_view, std::string_view);
friend Family<Histogram> create_histogram(std::string_view, std::string_view,
std::span<const double>);
struct State;
State *p;
};
// Factory functions for creating metric families
// Create counter family (monotonically increasing values)
// ERROR: Aborts if family with same name is registered with different help
// text.
Family<Counter> create_counter(std::string_view name, std::string_view help);
// Create gauge family (can increase/decrease)
// ERROR: Aborts if family with same name is registered with different help
// text.
Family<Gauge> create_gauge(std::string_view name, std::string_view help);
// Create histogram family with custom buckets
// Buckets will be sorted, deduplicated, and +Inf will be added automatically
// ERROR: Aborts if family with same name is registered with different help text
// or buckets.
Family<Histogram> create_histogram(std::string_view name, std::string_view help,
std::span<const double> buckets);
inline Family<Histogram>
create_histogram(std::string_view name, std::string_view help,
std::initializer_list<double> buckets) {
return create_histogram(
name, help, std::span<const double>(buckets.begin(), buckets.end()));
}
// Helper functions for generating standard histogram buckets
// Following Prometheus client library conventions
// Generate linear buckets: start, start+width, start+2*width, ...,
// start+(count-1)*width Example: linear_buckets(0, 10, 5) = {0, 10, 20, 30, 40}
std::vector<double> linear_buckets(double start, double width, int count);
// Generate exponential buckets: start, start*factor, start*factor^2, ...,
// start*factor^(count-1) Example: exponential_buckets(1, 2, 5) = {1, 2, 4, 8,
// 16}
std::vector<double> exponential_buckets(double start, double factor, int count);
// Render all metrics in Prometheus text format
// Returns chunks of Prometheus exposition format (includes # HELP and # TYPE
// lines) Each string_view may contain multiple lines separated by '\n' String
// views are NOT null-terminated - use .size() for length All string data
// allocated in provided arena for zero-copy efficiency. The caller is
// responsible for the arena's lifecycle. THREAD SAFETY: Serialized by global
// mutex - callbacks need not be thread-safe
std::span<std::string_view> render(Arena &arena);
// Validation functions for Prometheus compatibility
bool is_valid_metric_name(std::string_view name);
bool is_valid_label_key(std::string_view key);
bool is_valid_label_value(std::string_view value);
// Reset all metrics state - WARNING: Only safe for testing!
// This clears all registered families and metrics. Should only be called
// when no metric objects are in use and no concurrent render() calls.
void reset_metrics_for_testing();
/**
* @brief Interface for a custom collector that can be registered with the
* metrics system.
*
* This is used for complex metric gathering, such as reading from /proc, where
* multiple metrics need to be updated from a single data source.
*/
struct Collector {
/**
* @brief Virtual destructor.
*/
virtual ~Collector() = default;
/**
* @brief Called by the metrics system to update the metrics this collector is
* responsible for.
*/
virtual void collect() = 0;
};
/**
* @brief Register a collector with the metrics system.
*
* The system will hold a Ref to the collector and call its collect()
* method during each metric rendering.
*
* @param collector A Ref to the collector to be registered.
*/
void register_collector(Ref<Collector> collector);
// Note: Histograms do not support callbacks due to their multi-value nature
// (buckets + sum + count). Use static histogram metrics only.
} // namespace metric
+5 -1
View File
@@ -1,11 +1,15 @@
#pragma once
#define ENABLE_PERFETTO 1
#ifndef ENABLE_PERFETTO
#define ENABLE_PERFETTO 0
#endif
#if ENABLE_PERFETTO
#include <perfetto.h>
#else
#define PERFETTO_DEFINE_CATEGORIES(...)
#define PERFETTO_TRACK_EVENT_STATIC_STORAGE \
void perfetto_track_event_static_storage
#define TRACE_EVENT(...)
#endif
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include "arena.hpp"
#include "connection.hpp"
#include <variant>
// Forward declarations
struct CommitRequest;
/**
* Pipeline entry for commit requests that need full 4-stage processing.
* Contains connection with parsed CommitRequest.
*/
struct CommitEntry {
WeakRef<MessageSender> connection;
int64_t assigned_version = -1; // Set by sequence stage
bool resolve_success = false; // Set by resolve stage
bool persist_success = false; // Set by persist stage
// Protocol-agnostic handle for correlating the response
ProtocolHandle handle = -1;
const CommitRequest *commit_request = nullptr; // Points to request_arena data
// Request arena contains parsed request data and response data
Arena request_arena;
// JSON response body (set by persist stage, arena-allocated)
std::string_view response_json;
CommitEntry() = default; // Default constructor for variant
explicit CommitEntry(WeakRef<MessageSender> conn, ProtocolHandle handle,
const CommitRequest *req, Arena arena)
: connection(std::move(conn)), handle(handle), commit_request(req),
request_arena(std::move(arena)) {}
};
/**
* Pipeline entry for status requests that need sequence stage processing
* then transfer to status threadpool.
*/
struct StatusEntry {
WeakRef<MessageSender> connection;
int64_t version_upper_bound = 0; // Set by sequence stage
// Protocol-agnostic handle for correlating the response
ProtocolHandle handle = -1;
std::string_view status_request_id; // Points to request_arena data
// Request arena for request data
Arena request_arena;
// JSON response body (set by persist stage, arena-allocated)
std::string_view response_json;
StatusEntry() = default; // Default constructor for variant
explicit StatusEntry(WeakRef<MessageSender> conn, ProtocolHandle handle,
std::string_view request_id, Arena arena)
: connection(std::move(conn)), handle(handle),
status_request_id(request_id), request_arena(std::move(arena)) {}
};
/**
* Pipeline entry for /ok health check requests.
* Flows through all pipeline stages as a noop except resolve stage.
* Resolve stage can perform configurable CPU work for benchmarking.
*/
struct HealthCheckEntry {
WeakRef<MessageSender> connection;
// Protocol-agnostic handle for correlating the response
ProtocolHandle handle = -1;
// Request arena for response data
Arena request_arena;
// JSON response body (set by persist stage, arena-allocated)
std::string_view response_json;
HealthCheckEntry() = default; // Default constructor for variant
explicit HealthCheckEntry(WeakRef<MessageSender> conn, ProtocolHandle handle,
Arena arena)
: connection(std::move(conn)), handle(handle),
request_arena(std::move(arena)) {}
};
/**
* Pipeline entry for /v1/version requests.
* Needs to integrate with the pipeline because for external consistency.
*/
struct GetVersionEntry {
WeakRef<MessageSender> connection;
// Protocol-agnostic handle for correlating the response
ProtocolHandle handle = -1;
// Request arena for response data
Arena request_arena;
// JSON response body (set by persist stage, arena-allocated)
std::string_view response_json;
// Proposed response version
int64_t version;
GetVersionEntry() = default; // Default constructor for variant
explicit GetVersionEntry(WeakRef<MessageSender> conn, ProtocolHandle handle,
Arena arena, int64_t version)
: connection(std::move(conn)), handle(handle),
request_arena(std::move(arena)), version(version) {}
};
/**
* Pipeline entry for coordinated shutdown of all stages.
* Flows through all stages to ensure proper cleanup.
*/
struct ShutdownEntry {
// Empty struct - presence indicates shutdown
};
/**
* Pipeline entry variant type used by the commit processing pipeline.
* Each stage pattern-matches on the variant type to handle appropriately.
*/
using PipelineEntry = std::variant<CommitEntry, StatusEntry, HealthCheckEntry,
ShutdownEntry, GetVersionEntry>;
+239
View File
@@ -0,0 +1,239 @@
#include "process_collector.hpp"
#include <cstdio>
#include <cstring>
#include <dirent.h>
#include <sys/resource.h>
#include <unistd.h>
#include <vector>
namespace {
// Helper function to read the system boot time from /proc/stat.
// Returns boot time in seconds since epoch, or 0 on error.
double get_boot_time() {
FILE *fp = std::fopen("/proc/stat", "r");
if (!fp) {
return 0;
}
char line[256];
double boot_time = 0;
while (std::fgets(line, sizeof(line), fp)) {
if (std::strncmp(line, "btime ", 6) == 0) {
if (std::sscanf(line + 6, "%lf", &boot_time) != 1) {
boot_time = 0;
}
break;
}
}
std::fclose(fp);
return boot_time;
}
} // namespace
ProcessCollector::ProcessCollector()
: cpu_seconds_total_(metric::create_counter(
"process_cpu_seconds_total",
"Total user and system CPU time spent in seconds")
.create({})),
resident_memory_bytes_(
metric::create_gauge("process_resident_memory_bytes",
"Resident memory size in bytes")
.create({})),
virtual_memory_bytes_(metric::create_gauge("process_virtual_memory_bytes",
"Virtual memory size in bytes")
.create({})),
open_fds_(metric::create_gauge("process_open_fds",
"Number of open file descriptors")
.create({})),
max_fds_(metric::create_gauge("process_max_fds",
"Maximum number of open file descriptors")
.create({})),
start_time_seconds_(
metric::create_gauge(
"process_start_time_seconds",
"Start time of the process since unix epoch in seconds")
.create({})),
threads_(metric::create_gauge("process_threads",
"Number of OS threads in this process")
.create({})),
context_switches_total_voluntary_(
metric::create_counter("process_context_switches_total",
"Total number of context switches")
.create({{"type", "voluntary"}})),
context_switches_total_nonvoluntary_(
metric::create_counter("process_context_switches_total",
"Total number of context switches")
.create({{"type", "nonvoluntary"}})),
page_faults_total_minor_(
metric::create_counter("process_page_faults_total",
"Total number of page faults")
.create({{"type", "minor"}})),
page_faults_total_major_(
metric::create_counter("process_page_faults_total",
"Total number of page faults")
.create({{"type", "major"}})) {
// Set the constant max_fds metric.
struct rlimit rlim;
if (getrlimit(RLIMIT_NOFILE, &rlim) == 0) {
max_fds_.set(rlim.rlim_cur);
}
// Perform an initial collection to populate the other metrics and set the
// initial counter values.
collect();
}
void ProcessCollector::collect() {
// --- CPU Time, Memory, and Start Time from /proc/self/stat ---
FILE *fp = std::fopen("/proc/self/stat", "r");
if (!fp) {
return;
}
char buf[2048];
if (std::fgets(buf, sizeof(buf), fp) == nullptr) {
std::fclose(fp);
return;
}
std::fclose(fp);
// Find the end of the command name, which is in parentheses
const char *stats_start = std::strrchr(buf, ')');
if (!stats_start) {
return;
}
stats_start += 2; // Skip the ')' and the space
// Tokenize the rest of the string
std::vector<const char *> stats;
char *p = const_cast<char *>(stats_start);
while (*p) {
stats.push_back(p);
while (*p && *p != ' ') {
p++;
}
if (*p) {
*p = '\0';
p++;
}
}
// We need at least 24 fields for rss, and also fields 9,11 for page faults
if (stats.size() < 24) {
return;
}
long clk_tck = sysconf(_SC_CLK_TCK);
// --- Page Faults ---
unsigned long long minor_faults = std::strtoull(stats[7], nullptr, 10);
unsigned long long major_faults = std::strtoull(stats[9], nullptr, 10);
if (last_minor_faults_ > 0) {
if (minor_faults > last_minor_faults_) {
page_faults_total_minor_.inc(minor_faults - last_minor_faults_);
}
} else {
page_faults_total_minor_.inc(minor_faults);
}
last_minor_faults_ = minor_faults;
if (last_major_faults_ > 0) {
if (major_faults > last_major_faults_) {
page_faults_total_major_.inc(major_faults - last_major_faults_);
}
} else {
page_faults_total_major_.inc(major_faults);
}
last_major_faults_ = major_faults;
// --- CPU Time ---
unsigned long long utime_ticks = std::strtoull(stats[11], nullptr, 10);
unsigned long long stime_ticks = std::strtoull(stats[12], nullptr, 10);
unsigned long long current_total_ticks = utime_ticks + stime_ticks;
if (last_total_ticks_ > 0) { // If we have a previous value
if (current_total_ticks > last_total_ticks_) {
double delta_seconds =
(double)(current_total_ticks - last_total_ticks_) / clk_tck;
cpu_seconds_total_.inc(delta_seconds);
}
} else { // First run, initialize the counter
cpu_seconds_total_.inc((double)current_total_ticks / clk_tck);
}
last_total_ticks_ = current_total_ticks;
// --- Memory ---
unsigned long long vsize = std::strtoull(stats[20], nullptr, 10);
long rss_pages = std::strtol(stats[21], nullptr, 10);
virtual_memory_bytes_.set(vsize);
resident_memory_bytes_.set(rss_pages * sysconf(_SC_PAGESIZE));
// --- Start Time (only needs to be set once) ---
if (!start_time_set_) {
long long start_time_ticks = std::strtoll(stats[19], nullptr, 10);
double boot_time = get_boot_time();
if (boot_time > 0) {
start_time_seconds_.set(boot_time + (double)start_time_ticks / clk_tck);
start_time_set_ = true;
}
}
// --- File Descriptors ---
int fd_count = 0;
DIR *dp = opendir("/proc/self/fd");
if (dp) {
while (readdir(dp) != nullptr) {
fd_count++;
}
closedir(dp);
// Subtract 3 for '.', '..', and the opendir handle itself
open_fds_.set(fd_count > 3 ? fd_count - 3 : 0);
}
// --- Parse /proc/self/status for additional metrics ---
FILE *status_fp = std::fopen("/proc/self/status", "r");
if (status_fp) {
char status_line[256];
while (std::fgets(status_line, sizeof(status_line), status_fp)) {
if (std::strncmp(status_line, "Threads:\t", 9) == 0) {
int thread_count;
if (std::sscanf(status_line + 9, "%d", &thread_count) == 1) {
threads_.set(thread_count);
}
} else if (std::strncmp(status_line, "voluntary_ctxt_switches:\t", 25) ==
0) {
unsigned long long voluntary_switches;
if (std::sscanf(status_line + 25, "%llu", &voluntary_switches) == 1) {
if (last_voluntary_context_switches_ > 0) {
if (voluntary_switches > last_voluntary_context_switches_) {
context_switches_total_voluntary_.inc(
voluntary_switches - last_voluntary_context_switches_);
}
} else {
context_switches_total_voluntary_.inc(voluntary_switches);
}
last_voluntary_context_switches_ = voluntary_switches;
}
} else if (std::strncmp(status_line, "nonvoluntary_ctxt_switches:\t",
29) == 0) {
unsigned long long nonvoluntary_switches;
if (std::sscanf(status_line + 29, "%llu", &nonvoluntary_switches) ==
1) {
if (last_nonvoluntary_context_switches_ > 0) {
if (nonvoluntary_switches > last_nonvoluntary_context_switches_) {
context_switches_total_nonvoluntary_.inc(
nonvoluntary_switches - last_nonvoluntary_context_switches_);
}
} else {
context_switches_total_nonvoluntary_.inc(nonvoluntary_switches);
}
last_nonvoluntary_context_switches_ = nonvoluntary_switches;
}
}
}
std::fclose(status_fp);
}
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include "metric.hpp"
/**
* @brief A metric collector for standard process-level statistics.
*
* Gathers metrics like CPU usage, memory, and file descriptors by reading
* files from the /proc filesystem.
*/
struct ProcessCollector : metric::Collector {
/**
* @brief Constructs the collector and initializes the process metrics.
*/
ProcessCollector();
/**
* @brief Called by the metrics system to update the process metrics.
*/
void collect() override;
private:
// Metrics for process statistics
metric::Counter cpu_seconds_total_;
metric::Gauge resident_memory_bytes_;
metric::Gauge virtual_memory_bytes_;
metric::Gauge open_fds_;
metric::Gauge max_fds_;
metric::Gauge start_time_seconds_;
// Additional process metrics from /proc/self/status
metric::Gauge threads_;
metric::Counter context_switches_total_voluntary_;
metric::Counter context_switches_total_nonvoluntary_;
// Page fault metrics from /proc/self/stat
metric::Counter page_faults_total_minor_;
metric::Counter page_faults_total_major_;
// Last observed values for calculating counter increments
unsigned long long last_total_ticks_ = 0;
unsigned long long last_minor_faults_ = 0;
unsigned long long last_major_faults_ = 0;
unsigned long long last_voluntary_context_switches_ = 0;
unsigned long long last_nonvoluntary_context_switches_ = 0;
bool start_time_set_ = false;
};
+537
View File
@@ -0,0 +1,537 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
/**
* @brief High-performance thread-safe reference counting system
*
* This library provides custom smart pointers with shared/weak semantics,
* designed for better performance than std::shared_ptr/weak_ptr.
*
* Key features:
* - Thread-safe reference counting using atomic operations
* - Weak references to break circular dependencies
* - Single allocation for better cache locality
* - Optimized for copy/move operations
* - Compatible with std::shared_ptr semantics
*
* Basic usage:
* @code
* auto obj = make_ref<MyClass>(args...); // Create managed object
* auto copy = obj.copy(); // Explicit copy (thread-safe)
* WeakRef<MyClass> weak = obj.as_weak(); // Create weak reference
* auto locked = weak.lock(); // Try to promote to strong
* @endcode
*
* Thread safety: All operations are thread-safe. Multiple threads can
* safely copy, move, and destroy references to the same object.
*/
// Forward declaration
template <typename T> struct WeakRef;
namespace detail {
struct ControlBlock {
std::atomic<uint32_t> strong_count;
std::atomic<uint32_t> weak_count;
ControlBlock()
: strong_count(1), weak_count(1) {
} // Start with 1 strong, 1 weak (biased)
/**
* @brief Increment strong reference count
* @return Previous strong count
*/
uint32_t increment_strong() noexcept {
return strong_count.fetch_add(1, std::memory_order_relaxed);
}
/**
* @brief Decrement strong reference count
* @return Previous strong count
*/
uint32_t decrement_strong() noexcept {
return strong_count.fetch_sub(1, std::memory_order_acq_rel);
}
/**
* @brief Increment weak reference count
* @return Previous weak count
*/
uint32_t increment_weak() noexcept {
return weak_count.fetch_add(1, std::memory_order_relaxed);
}
/**
* @brief Decrement weak reference count
* @return Previous weak count
*/
uint32_t decrement_weak() noexcept {
return weak_count.fetch_sub(1, std::memory_order_acq_rel);
}
};
} // namespace detail
/**
* @brief Strong reference to a shared object (similar to std::shared_ptr)
*
* Ref<T> manages shared ownership of an object. The object is automatically
* destroyed when the last Ref pointing to it is destroyed.
*
* Usage:
* - Use make_ref<T>() to create new objects
* - Use copy() method for explicit sharing of ownership
* - Use get(), operator*, operator-> to access the object
* - Use operator bool() to check if valid
* - Use reset() to release ownership
*
* Limitations compared to std::shared_ptr:
* - Cannot take ownership of raw pointers
* - Objects can only be created via make_ref<T>() for proper memory layout
* - No custom deleter support
* - No enable_shared_from_this / shared_from_this() integration
* - No aliasing constructor (sharing ownership with different pointer)
* - No array support (Ref<T[]>)
* - No atomic operations (atomic_load, atomic_store, etc.)
*
* Thread safety: All operations are thread-safe. The managed object
* itself is NOT automatically thread-safe.
*/
template <typename T> struct Ref {
/**
* @brief Get raw pointer to managed object
*/
T *get() const noexcept { return ptr; }
/**
* @brief Dereference operator
*/
T &operator*() const { return *ptr; }
/**
* @brief Arrow operator
*/
T *operator->() const { return ptr; }
/**
* @brief Check if Ref is valid (not empty)
*/
explicit operator bool() const noexcept { return ptr != nullptr; }
/**
* @brief Destructor - decrements strong reference count
*/
~Ref() { release(); }
/**
* @brief Copy constructor - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
Ref(const Ref &other) = delete;
/**
* @brief Converting copy constructor - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
template <typename U> Ref(const Ref<U> &other) = delete;
/**
* @brief Copy assignment operator - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
Ref &operator=(const Ref &other) = delete;
/**
* @brief Converting assignment operator - deleted to prevent accidental
* copies Use copy() method for explicit copying
*/
template <typename U> Ref &operator=(const Ref<U> &other) = delete;
/**
* @brief Move constructor - transfers ownership
*/
Ref(Ref &&other) noexcept
: ptr(other.ptr), control_block(other.control_block) {
other.ptr = nullptr;
other.control_block = nullptr;
}
/**
* @brief Converting move constructor for polymorphism (Derived -> Base)
*/
template <typename U>
Ref(Ref<U> &&other) noexcept
requires std::is_convertible_v<U *, T *>
: ptr(other.ptr), control_block(other.control_block) {
other.ptr = nullptr;
other.control_block = nullptr;
}
/**
* @brief Move assignment operator
*/
Ref &operator=(Ref &&other) noexcept {
if (this != &other) {
release();
ptr = other.ptr;
control_block = other.control_block;
other.ptr = nullptr;
other.control_block = nullptr;
}
return *this;
}
/**
* @brief Converting move assignment operator for polymorphism (Derived ->
* Base)
*/
template <typename U>
Ref &operator=(Ref<U> &&other) noexcept
requires std::is_convertible_v<U *, T *>
{
release();
ptr = other.ptr;
control_block = other.control_block;
other.ptr = nullptr;
other.control_block = nullptr;
return *this;
}
/**
* @brief Explicitly create a copy with shared ownership
* @return New Ref that shares ownership of the same object
*/
[[nodiscard]] Ref copy() const noexcept {
if (control_block) {
control_block->increment_strong();
}
return Ref(ptr, control_block);
}
/**
* @brief Create a WeakRef that observes this object
* @return New WeakRef that observes the same object
*/
[[nodiscard]] WeakRef<T> as_weak() const noexcept {
if (control_block) {
control_block->increment_weak();
}
return WeakRef<T>(ptr, control_block);
}
/**
* @brief Reset to empty state
*/
void reset() noexcept {
release();
ptr = nullptr;
control_block = nullptr;
}
/**
* @brief Equality comparison
*/
bool operator==(const Ref &other) const noexcept {
return control_block == other.control_block;
}
/**
* @brief Inequality comparison
*/
bool operator!=(const Ref &other) const noexcept { return !(*this == other); }
/**
* @brief Default constructor - creates empty Ref
*/
Ref() : ptr(nullptr), control_block(nullptr) {}
private:
explicit Ref(T *object_ptr, detail::ControlBlock *cb)
: ptr(object_ptr), control_block(cb) {}
T *ptr;
detail::ControlBlock *control_block;
/**
* @brief Release current reference and handle cleanup
*/
void release() noexcept {
if (control_block) {
uint32_t prev_strong = control_block->decrement_strong();
// If this was the last strong reference, destroy the object
if (prev_strong == 1) {
// We need to call the destructor before we decrement the weak count, to
// account for the possibility that T has a WeakRef to itself.
ptr->~T();
// Release the bias - decrement weak count for strong references
uint32_t prev_weak = control_block->decrement_weak();
// If weak count hits 0, destroy and free control block
if (prev_weak == 1) {
control_block->~ControlBlock();
std::free(control_block);
}
}
}
}
template <typename U, typename... Args>
friend Ref<U> make_ref(Args &&...args);
template <typename U> friend struct WeakRef;
template <typename U> friend struct Ref;
};
/**
* @brief Weak reference to a shared object (similar to std::weak_ptr)
*
* WeakRef<T> holds a non-owning reference to an object managed by Ref<T>.
* It can be used to break circular dependencies and safely observe objects
* that might be destroyed by other threads.
*
* Usage:
* - Create from Ref<T> using as_weak() to observe without owning
* - Use copy() method for explicit copying
* - Use lock() to attempt promotion to Ref<T>
* - Returns empty Ref<T> if object was already destroyed
* - Use reset() to stop observing
*
* Self-referencing pattern: Objects can safely contain WeakRef members
* pointing to themselves. The implementation ensures proper destruction
* order to prevent use-after-free when the object destructor runs.
*
* Thread safety: All operations are thread-safe. The observed object
* may be destroyed by other threads at any time.
*/
template <typename T> struct WeakRef {
/**
* @brief Attempt to promote WeakRef to Ref
* @return Valid Ref if object still alive, empty Ref otherwise
*/
Ref<T> lock() const {
if (!control_block) {
return Ref<T>();
}
// Try to increment strong count if it's not zero
uint32_t expected_strong =
control_block->strong_count.load(std::memory_order_relaxed);
while (expected_strong > 0) {
// Try to increment the strong count
if (control_block->strong_count.compare_exchange_weak(
expected_strong, expected_strong + 1, std::memory_order_acquire,
std::memory_order_relaxed)) {
// Success - we incremented the strong count
return Ref<T>(ptr, control_block);
}
// CAS failed, expected_strong now contains the current value, retry
}
// Strong count was 0, object is being destroyed
return Ref<T>();
}
/**
* @brief Destructor - decrements weak reference count
*/
~WeakRef() { release(); }
/**
* @brief Copy constructor from WeakRef - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
WeakRef(const WeakRef &other) = delete;
/**
* @brief Copy constructor from Ref - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
WeakRef(const Ref<T> &ref) = delete;
/**
* @brief Converting copy constructor from WeakRef - deleted to prevent
* accidental copies Use copy() method for explicit copying
*/
template <typename U> WeakRef(const WeakRef<U> &other) = delete;
/**
* @brief Converting copy constructor from Ref - deleted to prevent accidental
* copies Use copy() method for explicit copying
*/
template <typename U> WeakRef(const Ref<U> &ref) = delete;
/**
* @brief Converting copy assignment from WeakRef - deleted to prevent
* accidental copies Use copy() method for explicit copying
*/
template <typename U> WeakRef &operator=(const WeakRef<U> &other) = delete;
/**
* @brief Converting copy assignment from Ref - deleted to prevent accidental
* copies Use copy() method for explicit copying
*/
template <typename U> WeakRef &operator=(const Ref<U> &ref) = delete;
/**
* @brief Converting move constructor from WeakRef for polymorphism
*/
template <typename U>
WeakRef(WeakRef<U> &&other) noexcept
requires std::is_convertible_v<U *, T *>
: ptr(other.ptr), control_block(other.control_block) {
other.ptr = nullptr;
other.control_block = nullptr;
}
/**
* @brief Converting move assignment from WeakRef for polymorphism
*/
template <typename U>
WeakRef &operator=(WeakRef<U> &&other) noexcept
requires std::is_convertible_v<U *, T *>
{
release();
ptr = other.ptr;
control_block = other.control_block;
other.ptr = nullptr;
other.control_block = nullptr;
return *this;
}
/**
* @brief Copy assignment from WeakRef - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
WeakRef &operator=(const WeakRef &other) = delete;
/**
* @brief Copy assignment from Ref - deleted to prevent accidental copies
* Use copy() method for explicit copying
*/
WeakRef &operator=(const Ref<T> &ref) = delete;
/**
* @brief Move constructor
*/
WeakRef(WeakRef &&other) noexcept
: ptr(other.ptr), control_block(other.control_block) {
other.ptr = nullptr;
other.control_block = nullptr;
}
/**
* @brief Move assignment
*/
WeakRef &operator=(WeakRef &&other) noexcept {
if (this != &other) {
release();
ptr = other.ptr;
control_block = other.control_block;
other.ptr = nullptr;
other.control_block = nullptr;
}
return *this;
}
/**
* @brief Explicitly create a copy with shared weak reference
* @return New WeakRef that observes the same object
*/
[[nodiscard]] WeakRef copy() const noexcept {
if (control_block) {
control_block->increment_weak();
}
return WeakRef(ptr, control_block);
}
/**
* @brief Reset to empty state
*/
void reset() noexcept {
release();
ptr = nullptr;
control_block = nullptr;
}
/**
* @brief Default constructor - creates empty WeakRef
*/
WeakRef() : ptr(nullptr), control_block(nullptr) {}
private:
explicit WeakRef(T *object_ptr, detail::ControlBlock *cb)
: ptr(object_ptr), control_block(cb) {}
T *ptr;
detail::ControlBlock *control_block;
/**
* @brief Release current weak reference and handle cleanup
*/
void release() noexcept {
if (control_block) {
uint32_t prev_weak = control_block->decrement_weak();
// If weak count hits 0, destroy and free control block
if (prev_weak == 1) {
control_block->~ControlBlock();
std::free(control_block);
}
}
}
template <typename U> friend struct Ref;
template <typename U> friend struct WeakRef;
};
/**
* @brief Create a new managed object wrapped in Ref<T>
*
* This is the only way to create Ref<T> objects. It performs a single
* allocation for both the control block and object, improving cache locality.
*
* @tparam T Type of object to create
* @tparam Args Types of constructor arguments
* @param args Arguments forwarded to T's constructor
* @return Ref<T> managing the newly created object
*
* Example:
* @code
* auto obj = make_ref<MyClass>(arg1, arg2);
* auto empty_vec = make_ref<std::vector<int>>();
* auto obj_copy = obj.copy(); // Explicit copy
* WeakRef<MyClass> weak = obj.as_weak(); // Create weak reference
* @endcode
*
* Thread safety: Safe to call from multiple threads simultaneously.
*/
template <typename T, typename... Args> Ref<T> make_ref(Args &&...args) {
constexpr size_t cb_size = sizeof(detail::ControlBlock);
constexpr size_t alignment = alignof(T);
constexpr size_t padded_cb_size =
(cb_size + alignment - 1) & ~(alignment - 1);
constexpr size_t total_alignment =
std::max(alignof(detail::ControlBlock), alignment);
constexpr size_t total_size = padded_cb_size + sizeof(T);
constexpr size_t aligned_total_size =
(total_size + total_alignment - 1) & ~(total_alignment - 1);
char *buf = reinterpret_cast<char *>(
std::aligned_alloc(total_alignment, aligned_total_size));
if (!buf) {
std::fprintf(stderr, "Out of memory\n");
std::abort();
}
auto *cb = new (buf) detail::ControlBlock();
T *obj = new (buf + padded_cb_size) T{std::forward<Args>(args)...};
return Ref<T>(obj, cb);
}
+102 -148
View File
@@ -1,11 +1,9 @@
#include "server.hpp"
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <memory>
#include <netdb.h>
#include <netinet/tcp.h>
#include <pthread.h>
@@ -21,12 +19,12 @@
// Static thread-local storage for read buffer (used across different functions)
static thread_local std::vector<char> g_read_buffer;
std::shared_ptr<Server> Server::create(const weaseldb::Config &config,
Ref<Server> Server::create(const weaseldb::Config &config,
ConnectionHandler &handler,
const std::vector<int> &listen_fds) {
// Use std::shared_ptr constructor with private access
// We can't use make_shared here because constructor is private
return std::shared_ptr<Server>(new Server(config, handler, listen_fds));
auto result = make_ref<Server>(config, handler, listen_fds);
result->self_ = result.as_weak();
return result;
}
Server::Server(const weaseldb::Config &config, ConnectionHandler &handler,
@@ -76,7 +74,7 @@ Server::~Server() {
}
// Close all epoll instances
for (int epollfd : epoll_fds_) {
for (auto [epollfd] : event_loops_) {
if (epollfd != -1) {
int e = close(epollfd);
if (e == -1 && errno != EINTR) {
@@ -85,7 +83,7 @@ Server::~Server() {
}
}
}
epoll_fds_.clear();
event_loops_.clear();
// Close all listen sockets (Server always owns them)
for (int fd : listen_fds_) {
@@ -98,9 +96,11 @@ Server::~Server() {
}
}
// Clean up unix socket file if it exists
if (!config_.server.unix_socket_path.empty()) {
unlink(config_.server.unix_socket_path.c_str());
// Clean up unix socket files if they exist
for (const auto &iface : config_.server.interfaces) {
if (iface.type == weaseldb::ListenInterface::Type::Unix) {
unlink(iface.path.c_str());
}
}
}
@@ -137,51 +137,6 @@ void Server::shutdown() {
}
}
void Server::release_back_to_server(std::unique_ptr<Connection> connection) {
if (!connection) {
return; // Nothing to release
}
// Try to get the server from the connection's weak_ptr
if (auto server = connection->server_.lock()) {
// Server still exists - pass unique_ptr directly
server->receiveConnectionBack(std::move(connection));
}
// If server is gone, connection will be automatically cleaned up when
// unique_ptr destructs
}
void Server::receiveConnectionBack(std::unique_ptr<Connection> connection) {
if (!connection) {
return; // Nothing to process
}
// Re-add the connection to epoll for continued processing
struct epoll_event event{};
if (!connection->hasMessages()) {
event.events = EPOLLIN | EPOLLONESHOT;
} else {
event.events = EPOLLOUT | EPOLLONESHOT;
}
int fd = connection->getFd();
event.data.fd = fd;
// Store connection in registry before adding to epoll
// This mirrors the pattern used in process_connection_batch
size_t epoll_index = connection->getEpollIndex();
int epollfd = epoll_fds_[epoll_index];
connection_registry_.store(fd, std::move(connection));
if (epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event) == -1) {
perror("epoll_ctl MOD in receiveConnectionBack");
// Remove from registry and clean up on failure
(void)connection_registry_.remove(fd);
}
}
int Server::create_local_connection() {
int sockets[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) {
@@ -208,25 +163,27 @@ int Server::create_local_connection() {
struct sockaddr_storage addr{};
addr.ss_family = AF_UNIX;
// Calculate epoll_index for connection distribution
// Use round-robin distribution for local connections across epoll instances
size_t epoll_index =
connection_distribution_counter_.fetch_add(1, std::memory_order_relaxed) %
epoll_fds_.size();
event_loops_.size();
// Create Connection object
auto connection = std::unique_ptr<Connection>(new Connection(
auto connection = make_ref<Connection>(
addr, server_fd, connection_id_.fetch_add(1, std::memory_order_relaxed),
epoll_index, &handler_, *this));
epoll_index, &handler_, self_.copy());
connection->self_ref_ = connection.as_weak();
connection->tsan_release();
// Store in registry
connection_registry_.store(server_fd, std::move(connection));
// Add to appropriate epoll instance
struct epoll_event event{};
event.events = EPOLLIN | EPOLLONESHOT;
event.events = EPOLLIN;
event.data.fd = server_fd;
int epollfd = epoll_fds_[epoll_index];
int epollfd = event_loops_[epoll_index].epoll_fd_;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
perror("epoll_ctl ADD local connection");
connection_registry_.remove(server_fd);
@@ -261,12 +218,13 @@ void Server::setup_shutdown_pipe() {
}
void Server::create_epoll_instances() {
// Create multiple epoll instances to reduce contention
epoll_fds_.resize(config_.server.epoll_instances);
// Create one epoll instance per I/O thread (1:1 mapping) to eliminate
// contention
event_loops_.resize(config_.server.io_threads);
for (int i = 0; i < config_.server.epoll_instances; ++i) {
epoll_fds_[i] = epoll_create1(EPOLL_CLOEXEC);
if (epoll_fds_[i] == -1) {
for (int i = 0; i < config_.server.io_threads; ++i) {
event_loops_[i].epoll_fd_ = epoll_create1(EPOLL_CLOEXEC);
if (event_loops_[i].epoll_fd_ == -1) {
perror("epoll_create1");
std::abort();
}
@@ -276,7 +234,7 @@ void Server::create_epoll_instances() {
shutdown_event.events = EPOLLIN;
shutdown_event.data.fd = shutdown_pipe_[0];
if (epoll_ctl(epoll_fds_[i], EPOLL_CTL_ADD, shutdown_pipe_[0],
if (epoll_ctl(event_loops_[i].epoll_fd_, EPOLL_CTL_ADD, shutdown_pipe_[0],
&shutdown_event) == -1) {
perror("epoll_ctl shutdown pipe");
std::abort();
@@ -288,8 +246,8 @@ void Server::create_epoll_instances() {
struct epoll_event listen_event;
listen_event.events = EPOLLIN | EPOLLEXCLUSIVE;
listen_event.data.fd = listen_fd;
if (epoll_ctl(epoll_fds_[i], EPOLL_CTL_ADD, listen_fd, &listen_event) ==
-1) {
if (epoll_ctl(event_loops_[i].epoll_fd_, EPOLL_CTL_ADD, listen_fd,
&listen_event) == -1) {
perror("epoll_ctl listen socket");
std::abort();
}
@@ -297,11 +255,6 @@ void Server::create_epoll_instances() {
}
}
int Server::get_epoll_for_thread(int thread_id) const {
// Round-robin assignment of threads to epoll instances
return epoll_fds_[thread_id % epoll_fds_.size()];
}
void Server::start_io_threads(std::vector<std::thread> &threads) {
int io_threads = config_.server.io_threads;
@@ -310,12 +263,11 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
pthread_setname_np(pthread_self(),
("io-" + std::to_string(thread_id)).c_str());
// Each thread uses its assigned epoll instance (round-robin)
int epollfd = get_epoll_for_thread(thread_id);
// Each thread uses its assigned epoll instance (1:1 mapping)
int epollfd = event_loops_[thread_id].epoll_fd_;
std::vector<epoll_event> events(config_.server.event_batch_size);
std::vector<std::unique_ptr<Connection>> batch(
config_.server.event_batch_size);
std::vector<Ref<Connection>> batch(config_.server.event_batch_size);
std::vector<int> batch_events(config_.server.event_batch_size);
std::vector<int>
ready_listen_fds; // Reused across iterations to avoid allocation
@@ -349,11 +301,12 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
// Handle existing connection events
int fd = events[i].data.fd;
std::unique_ptr<Connection> conn = connection_registry_.remove(fd);
Ref<Connection> conn = connection_registry_.remove(fd);
conn->tsan_acquire();
assert(conn);
if (events[i].events & (EPOLLERR | EPOLLHUP)) {
// unique_ptr will automatically delete on scope exit
close_connection(conn);
continue;
}
@@ -366,7 +319,7 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
// Process existing connections in batch
if (batch_count > 0) {
process_connection_batch(
epollfd, std::span(batch).subspan(0, batch_count),
std::span(batch).subspan(0, batch_count),
std::span(batch_events).subspan(0, batch_count));
}
@@ -406,9 +359,9 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
perror("setsockopt SO_KEEPALIVE");
}
// Add to epoll with no interests
// Add to epoll
struct epoll_event event{};
event.events = 0;
event.events = EPOLLIN;
event.data.fd = fd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event) == -1) {
perror("epoll_ctl ADD");
@@ -416,11 +369,13 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
}
// Transfer ownership from registry to batch processing
size_t epoll_index = thread_id % epoll_fds_.size();
batch[batch_count] = std::unique_ptr<Connection>(new Connection(
size_t epoll_index = thread_id;
batch[batch_count] = make_ref<Connection>(
addr, fd,
connection_id_.fetch_add(1, std::memory_order_relaxed),
epoll_index, &handler_, *this));
epoll_index, &handler_, self_.copy());
batch[batch_count]->self_ref_ = batch[batch_count].as_weak();
batch[batch_count]->tsan_release();
batch_events[batch_count] =
EPOLLIN; // New connections always start with read
batch_count++;
@@ -428,7 +383,7 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
// Process batch if full
if (batch_count == config_.server.event_batch_size) {
process_connection_batch(
epollfd, {batch.data(), (size_t)batch_count},
{batch.data(), (size_t)batch_count},
{batch_events.data(), (size_t)batch_count});
batch_count = 0;
}
@@ -438,7 +393,7 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
// Process remaining accepted connections
if (batch_count > 0) {
process_connection_batch(
epollfd, std::span(batch).subspan(0, batch_count),
std::span(batch).subspan(0, batch_count),
std::span(batch_events).subspan(0, batch_count));
batch_count = 0;
}
@@ -447,78 +402,86 @@ void Server::start_io_threads(std::vector<std::thread> &threads) {
}
}
void Server::process_connection_reads(std::unique_ptr<Connection> &conn,
int events) {
void Server::process_connection_reads(Ref<Connection> &conn, int events) {
assert(conn);
// Handle EPOLLIN - read data and process it
if (events & EPOLLIN) {
auto buf_size = config_.server.read_buffer_size;
g_read_buffer.resize(buf_size);
char *buf = g_read_buffer.data();
int r = conn->readBytes(buf, buf_size);
// Once we do EPOLLET we must drain the socket until read returns EAGAIN.
for (;;) {
int r = conn->read_bytes(buf, buf_size);
if (r < 0) {
// Error or EOF - connection should be closed
conn.reset();
close_connection(conn);
return;
}
if (r == 0) {
// No data available (EAGAIN) - skip read processing but continue
// No data available (EAGAIN) - read side drained
return;
}
// Call handler with unique_ptr - handler can take ownership if needed
handler_.on_data_arrived(std::string_view{buf, size_t(r)}, conn);
// Call handler with connection reference - server retains ownership.
handler_.on_data_arrived(std::string_view{buf, size_t(r)}, *conn);
// If handler took ownership (conn is now null), return true to indicate
// processing is done
// The connection may have been closed by the handler; stop reading.
if (!conn) {
return;
}
}
}
}
void Server::process_connection_writes(std::unique_ptr<Connection> &conn,
int events) {
void Server::process_connection_writes(Ref<Connection> &conn, int events) {
assert(conn);
// Send immediately if we have outgoing messages (either from EPOLLOUT or
// after reading)
if ((events & EPOLLOUT) || ((events & EPOLLIN) && conn->hasMessages())) {
bool had_messages = conn->hasMessages();
bool error = conn->writeBytes();
if (error) {
conn.reset(); // Connection should be closed
// Process pending responses first if this is an EPOLLOUT event
if (events & EPOLLOUT) {
std::unique_lock lock(conn->mutex_);
if (!conn->pending_response_queue_.empty()) {
std::vector<PendingResponse> pending_vec;
pending_vec.reserve(conn->pending_response_queue_.size());
for (auto &response : conn->pending_response_queue_) {
pending_vec.push_back(std::move(response));
}
conn->pending_response_queue_.clear();
lock.unlock();
handler_.on_preprocess_writes(*conn, std::span{pending_vec});
}
}
auto result = conn->write_bytes();
if (result & Connection::WriteBytesResult::Error) {
close_connection(conn);
return;
}
// Call handler with unique_ptr - handler can take ownership if needed
handler_.on_write_progress(conn);
// If handler took ownership (conn is now null), return true to indicate
// processing is done
if (!conn) {
return;
}
// Check if buffer became empty (transition from non-empty -> empty)
if (had_messages && !conn->hasMessages()) {
handler_.on_write_buffer_drained(conn);
// If handler took ownership (conn is now null), return
if (!conn) {
return;
}
if (result & Connection::WriteBytesResult::Progress) {
// Call handler with connection reference - server retains ownership
handler_.on_write_progress(*conn);
}
// Check if we should close the connection according to application
if (!conn->hasMessages() && conn->shouldClose()) {
conn.reset(); // Connection should be closed
if (result & Connection::WriteBytesResult::Close) {
close_connection(conn);
return;
}
}
}
void Server::process_connection_batch(
int epollfd, std::span<std::unique_ptr<Connection>> batch,
void Server::close_connection(Ref<Connection> &conn) {
conn->close();
conn.reset();
}
static thread_local std::vector<Connection *> batch_connections;
void Server::process_connection_batch(std::span<Ref<Connection>> batch,
std::span<const int> events) {
// First process writes for each connection
@@ -535,29 +498,20 @@ void Server::process_connection_batch(
}
}
// Call batch complete handler - handlers can take ownership here
handler_.on_batch_complete(batch);
// Transfer all remaining connections back to epoll
for (auto &conn_ptr : batch) {
if (conn_ptr) {
int fd = conn_ptr->getFd();
struct epoll_event event{};
if (!conn_ptr->hasMessages()) {
event.events = EPOLLIN | EPOLLONESHOT;
} else {
event.events = EPOLLOUT | EPOLLONESHOT;
// Call batch complete handler with connection pointers
batch_connections.clear();
for (auto &conn : batch) {
if (conn) {
batch_connections.push_back(conn.get());
}
event.data.fd = fd; // Use file descriptor for epoll
// Put connection back in registry since handler didn't take ownership.
// Must happen before epoll_ctl
connection_registry_.store(fd, std::move(conn_ptr));
if (epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event) == -1) {
perror("epoll_ctl MOD");
(void)connection_registry_.remove(fd);
}
handler_.on_batch_complete(batch_connections);
// Return all connections to registry
for (auto &conn : batch) {
if (conn) {
const int fd = conn->fd_;
connection_registry_.store(fd, std::move(conn));
}
}
}
+28 -46
View File
@@ -1,7 +1,6 @@
#pragma once
#include <atomic>
#include <memory>
#include <span>
#include <thread>
#include <vector>
@@ -9,6 +8,7 @@
#include "config.hpp"
#include "connection_handler.hpp"
#include "connection_registry.hpp"
#include "reference.hpp"
/**
* High-performance multi-threaded server for handling network connections.
@@ -28,18 +28,18 @@
*
* IMPORTANT: Server uses a factory pattern and MUST be created via
* Server::create(). This ensures:
* - Proper shared_ptr semantics for enable_shared_from_this
* - Safe weak_ptr references from Connection objects
* - Proper Ref<Server> semantics for reference counting
* - Safe WeakRef<Server> references from Connection objects
* - Prevention of accidental stack allocation that would break safety
* guarantees
*/
struct Server : std::enable_shared_from_this<Server> {
struct Server {
/**
* Factory method to create a Server instance.
*
* This is the only way to create a Server - ensures proper shared_ptr
* This is the only way to create a Server - ensures proper Ref<Server>
* semantics and prevents accidental stack allocation that would break
* weak_ptr safety.
* WeakRef<Server> safety.
*
* @param config Server configuration (threads, ports, limits, etc.)
* @param handler Protocol handler for processing connection data
@@ -47,9 +47,9 @@ struct Server : std::enable_shared_from_this<Server> {
* Server takes ownership and will close them on
* destruction. Server will set these to non-blocking mode for safe epoll
* usage. Empty vector means no listening sockets.
* @return shared_ptr to the newly created Server
* @return Ref to the newly created Server
*/
static std::shared_ptr<Server> create(const weaseldb::Config &config,
static Ref<Server> create(const weaseldb::Config &config,
ConnectionHandler &handler,
const std::vector<int> &listen_fds);
@@ -94,26 +94,14 @@ struct Server : std::enable_shared_from_this<Server> {
*/
int create_local_connection();
/**
* Release a connection back to its server for continued processing.
*
* This static method safely returns ownership of a connection back to its
* server. If the server has been destroyed, the connection will be safely
* cleaned up.
*
* This method is thread-safe and can be called from any thread.
*
* @param connection unique_ptr to the connection being released back
*/
static void release_back_to_server(std::unique_ptr<Connection> connection);
private:
friend struct Connection;
/**
* Private constructor - use create() factory method instead.
*
* @param config Server configuration (threads, ports, limits, etc.)
* @param handler Protocol handler for processing connection data
* @param handler Protocol handler for processing connection data. Must
* outlive the server.
* @param listen_fds Vector of file descriptors to accept connections on.
* Server takes ownership and will close them on
* destruction. Server will set these to non-blocking mode for safe epoll
@@ -121,8 +109,12 @@ private:
*/
explicit Server(const weaseldb::Config &config, ConnectionHandler &handler,
const std::vector<int> &listen_fds);
template <typename T, typename... Args>
friend Ref<T> make_ref(Args &&...args);
const weaseldb::Config &config_;
WeakRef<Server> self_;
weaseldb::Config config_;
ConnectionHandler &handler_;
// Connection registry
@@ -132,47 +124,37 @@ private:
std::atomic<int64_t> connection_id_{0};
std::atomic<int> active_connections_{0};
// Round-robin counter for connection distribution
// Round-robin counter for local connection distribution across epoll
// instances
std::atomic<size_t> connection_distribution_counter_{0};
// Shutdown coordination
int shutdown_pipe_[2] = {-1, -1};
// Multiple epoll file descriptors to reduce contention
std::vector<int> epoll_fds_;
struct EventLoopState {
int epoll_fd_;
};
// Multiple epoll file descriptors (1:1 with I/O threads) to reduce contention
std::vector<EventLoopState> event_loops_;
std::vector<int>
listen_fds_; // FDs to accept connections on (Server owns these)
// Private helper methods
void setup_shutdown_pipe();
void setup_signal_handling();
void create_epoll_instances();
void start_io_threads(std::vector<std::thread> &threads);
// Helper to get epoll fd for a thread using round-robin
int get_epoll_for_thread(int thread_id) const;
// Helper for processing connection I/O
void process_connection_reads(std::unique_ptr<Connection> &conn_ptr,
int events);
void process_connection_writes(std::unique_ptr<Connection> &conn_ptr,
int events);
void process_connection_reads(Ref<Connection> &conn, int events);
void process_connection_writes(Ref<Connection> &conn, int events);
void close_connection(Ref<Connection> &conn);
// Helper for processing a batch of connections with their events
void process_connection_batch(int epollfd,
std::span<std::unique_ptr<Connection>> batch,
void process_connection_batch(std::span<Ref<Connection>> batch,
std::span<const int> events);
/**
* Called internally to return ownership to the server.
*
* This method is thread-safe and can be called from any thread.
* The connection will be re-added to the epoll for continued processing.
*
* @param connection Unique pointer to the connection being released back
*/
void receiveConnectionBack(std::unique_ptr<Connection> connection);
// Make non-copyable and non-movable
Server(const Server &) = delete;
Server &operator=(const Server &) = delete;
+227 -174
View File
@@ -1,6 +1,5 @@
#pragma once
#include <array>
#include <atomic>
#include <cassert>
#include <cstddef>
@@ -48,151 +47,174 @@ struct ThreadState {
bool last_stage;
};
// Compile-time topology configuration for static pipelines
// Runtime topology configuration for dynamic pipelines
//
// This template defines a pipeline topology at compile-time:
// - Stage and thread calculations done at compile-time
// - Type-safe indexing: Stage and thread indices validated at compile-time
// - Fixed-size arrays with known bounds
// - Code specialization for each topology
// This class defines a pipeline topology at runtime:
// - Stage and thread calculations done at runtime
// - Flexible configuration: topology can be set via constructor
// - Dynamic arrays with runtime bounds checking
// - Single implementation works for any topology
//
// Example: StaticPipelineTopology<1, 4, 2> creates:
// Example: PipelineTopology({1, 4, 2}) creates:
// - Stage 0: 1 thread (index 0)
// - Stage 1: 4 threads (indices 1-4)
// - Stage 2: 2 threads (indices 5-6)
// - Total: 7 threads across 3 stages
template <int... ThreadsPerStage> struct StaticPipelineTopology {
static_assert(sizeof...(ThreadsPerStage) > 0,
"Must specify at least one stage");
static_assert(((ThreadsPerStage > 0) && ...),
"All stages must have at least one thread");
struct PipelineTopology {
const std::vector<int> threads_per_stage;
const int num_stages;
const std::vector<int> stage_offsets;
const int total_threads;
static constexpr int num_stages = sizeof...(ThreadsPerStage);
static constexpr std::array<int, num_stages> threads_per_stage = {
ThreadsPerStage...};
static constexpr int total_threads = (ThreadsPerStage + ...);
explicit PipelineTopology(std::vector<int> threads_per_stage_)
: threads_per_stage(validate_and_move(std::move(threads_per_stage_))),
num_stages(static_cast<int>(threads_per_stage.size())),
stage_offsets(build_stage_offsets(threads_per_stage)),
total_threads(build_total_threads(threads_per_stage)) {}
// Compile-time stage offset calculation
template <int Stage> static constexpr int stage_offset() {
static_assert(Stage >= 0 && Stage < num_stages,
"Stage index out of bounds");
if constexpr (Stage == 0) {
return 0;
} else {
return stage_offset<Stage - 1>() + threads_per_stage[Stage - 1];
// Runtime stage offset calculation
int stage_offset(int stage) const {
if (stage < 0 || stage >= num_stages) {
std::abort(); // Stage index out of bounds
}
return stage_offsets[stage];
}
// Compile-time thread index calculation
template <int Stage, int Thread> static constexpr int thread_index() {
static_assert(Stage >= 0 && Stage < num_stages,
"Stage index out of bounds");
static_assert(Thread >= 0 && Thread < threads_per_stage[Stage],
"Thread index out of bounds");
return stage_offset<Stage>() + Thread;
// Runtime thread index calculation
int thread_index(int stage, int thread) const {
if (stage < 0 || stage >= num_stages) {
std::abort(); // Stage index out of bounds
}
if (thread < 0 || thread >= threads_per_stage[stage]) {
std::abort(); // Thread index out of bounds
}
return stage_offsets[stage] + thread;
}
// Compile-time previous stage thread count
template <int Stage> static constexpr int prev_stage_thread_count() {
static_assert(Stage >= 0 && Stage < num_stages,
"Stage index out of bounds");
if constexpr (Stage == 0) {
// Runtime previous stage thread count
int prev_stage_thread_count(int stage) const {
if (stage < 0 || stage >= num_stages) {
std::abort(); // Stage index out of bounds
}
if (stage == 0) {
return 1;
} else {
return threads_per_stage[Stage - 1];
return threads_per_stage[stage - 1];
}
}
private:
static std::vector<int> validate_and_move(std::vector<int> threads) {
if (threads.empty()) {
std::abort(); // Must specify at least one stage
}
for (int count : threads) {
if (count <= 0) {
std::abort(); // All stages must have at least one thread
}
}
return threads;
}
static std::vector<int>
build_stage_offsets(const std::vector<int> &threads_per_stage) {
std::vector<int> offsets(threads_per_stage.size());
int offset = 0;
for (size_t i = 0; i < threads_per_stage.size(); ++i) {
offsets[i] = offset;
offset += threads_per_stage[i];
}
return offsets;
}
static int build_total_threads(const std::vector<int> &threads_per_stage) {
int total = 0;
for (int count : threads_per_stage) {
total += count;
}
return total;
}
};
// Static pipeline algorithms - compile-time specialized versions
namespace StaticPipelineAlgorithms {
// Pipeline algorithms - runtime configurable versions
namespace PipelineAlgorithms {
template <WaitStrategy wait_strategy, typename Topology, int Stage,
int ThreadInStage>
uint32_t calculate_safe_len(
std::array<ThreadState, Topology::total_threads> &all_threads,
std::atomic<uint32_t> &pushes, bool may_block) {
constexpr int thread_idx =
Topology::template thread_index<Stage, ThreadInStage>();
inline uint32_t calculate_safe_len(WaitStrategy wait_strategy,
const PipelineTopology &topology, int stage,
int thread_in_stage,
std::vector<ThreadState> &all_threads,
std::atomic<uint32_t> &pushes,
bool may_block) {
int thread_idx = topology.thread_index(stage, thread_in_stage);
auto &thread = all_threads[thread_idx];
uint32_t safe_len = UINT32_MAX;
constexpr int prev_stage_threads =
Topology::template prev_stage_thread_count<Stage>();
int prev_stage_threads = topology.prev_stage_thread_count(stage);
// Compile-time loop over previous stage threads
[&]<std::size_t... Is>(std::index_sequence<Is...>) {
(
[&] {
auto &last_push = [&]() -> std::atomic<uint32_t> & {
if constexpr (Stage == 0) {
// Runtime loop over previous stage threads
for (int i = 0; i < prev_stage_threads; ++i) {
std::atomic<uint32_t> &last_push = [&]() -> std::atomic<uint32_t> & {
if (stage == 0) {
return pushes;
} else {
constexpr int prev_thread_idx =
Topology::template thread_index<Stage - 1, Is>();
int prev_thread_idx = topology.thread_index(stage - 1, i);
return all_threads[prev_thread_idx].pops;
}
}();
if (thread.last_push_read[Is] == thread.local_pops) {
thread.last_push_read[Is] =
last_push.load(std::memory_order_acquire);
if (thread.last_push_read[Is] == thread.local_pops) {
if (thread.last_push_read[i] == thread.local_pops) {
thread.last_push_read[i] = last_push.load(std::memory_order_acquire);
if (thread.last_push_read[i] == thread.local_pops) {
if (!may_block) {
safe_len = 0;
return;
return safe_len;
}
if constexpr (wait_strategy == WaitStrategy::Never) {
if (wait_strategy == WaitStrategy::Never) {
// Empty - busy wait
} else if constexpr (wait_strategy ==
WaitStrategy::WaitIfUpstreamIdle) {
} else if (wait_strategy == WaitStrategy::WaitIfUpstreamIdle) {
// We're allowed to spin as long as we eventually go to 0% cpu
// usage on idle
uint32_t push;
for (int i = 0; i < 100000; ++i) {
bool should_wait = true;
for (int j = 0; j < 100000; ++j) {
push = pushes.load(std::memory_order_relaxed);
if (push != thread.local_pops) {
goto dont_wait;
should_wait = false;
break;
}
#if defined(__x86_64__) || defined(_M_X64)
_mm_pause();
#endif
}
if (should_wait) {
pushes.wait(push, std::memory_order_relaxed);
dont_wait:;
} else {
static_assert(wait_strategy == WaitStrategy::WaitIfStageEmpty);
last_push.wait(thread.last_push_read[Is],
std::memory_order_relaxed);
}
} else { // WaitStrategy::WaitIfStageEmpty
last_push.wait(thread.last_push_read[i], std::memory_order_relaxed);
}
thread.last_push_read[Is] =
last_push.load(std::memory_order_acquire);
thread.last_push_read[i] = last_push.load(std::memory_order_acquire);
}
}
safe_len =
std::min(safe_len, thread.last_push_read[Is] - thread.local_pops);
}(),
...);
}(std::make_index_sequence<prev_stage_threads>{});
safe_len = std::min(safe_len, thread.last_push_read[i] - thread.local_pops);
}
return safe_len;
}
template <WaitStrategy wait_strategy, typename Topology, int Stage,
int ThreadInStage>
void update_thread_pops(
std::array<ThreadState, Topology::total_threads> &all_threads,
inline void update_thread_pops(WaitStrategy wait_strategy,
const PipelineTopology &topology, int stage,
int thread_in_stage,
std::vector<ThreadState> &all_threads,
uint32_t local_pops) {
constexpr int thread_idx =
Topology::template thread_index<Stage, ThreadInStage>();
int thread_idx = topology.thread_index(stage, thread_in_stage);
auto &thread_state = all_threads[thread_idx];
if constexpr (wait_strategy == WaitStrategy::WaitIfStageEmpty) {
if (wait_strategy == WaitStrategy::WaitIfStageEmpty) {
thread_state.pops.store(local_pops, std::memory_order_seq_cst);
thread_state.pops.notify_all();
} else if constexpr (Stage == Topology::num_stages - 1) { // last stage
} else if (stage == topology.num_stages - 1) { // last stage
thread_state.pops.store(local_pops, std::memory_order_seq_cst);
thread_state.pops.notify_all();
} else {
@@ -200,15 +222,13 @@ void update_thread_pops(
}
}
template <typename Topology>
int check_producer_capacity(
std::array<ThreadState, Topology::total_threads> &all_threads,
uint32_t slot, uint32_t size, uint32_t slot_count, bool block) {
constexpr int last_stage = Topology::num_stages - 1;
constexpr int last_stage_offset =
Topology::template stage_offset<last_stage>();
constexpr int last_stage_thread_count =
Topology::threads_per_stage[last_stage];
inline int check_producer_capacity(const PipelineTopology &topology,
std::vector<ThreadState> &all_threads,
uint32_t slot, uint32_t size,
uint32_t slot_count, bool block) {
int last_stage = topology.num_stages - 1;
int last_stage_offset = topology.stage_offset(last_stage);
int last_stage_thread_count = topology.threads_per_stage[last_stage];
for (int i = 0; i < last_stage_thread_count; ++i) {
auto &thread = all_threads[last_stage_offset + i];
@@ -223,10 +243,10 @@ int check_producer_capacity(
}
return 0; // Can proceed
}
} // namespace StaticPipelineAlgorithms
} // namespace PipelineAlgorithms
// Static multi-stage lock-free pipeline for inter-thread communication
// with compile-time topology specification.
// Multi-stage lock-free pipeline for inter-thread communication
// with runtime-configurable topology and wait strategy.
//
// Overview:
// - Items flow from producers through multiple processing stages (stage 0 ->
@@ -234,25 +254,17 @@ int check_producer_capacity(
// - Each stage can have multiple worker threads processing items in parallel
// - Uses a shared ring buffer with atomic counters for lock-free coordination
// - Supports batch processing for efficiency
// - Compile-time topology specification via template parameters
// - Runtime-configurable topology and wait strategy via constructor parameters
//
// Architecture:
// - Producers: External threads that add items to the pipeline via push()
// - Stages: Processing stages numbered 0, 1, 2, ... that consume items via
// acquire<Stage, Thread>()
// acquire(stage, thread)
// - Items flow: Producers -> Stage 0 -> Stage 1 -> ... -> Final Stage
//
// Differences from Dynamic Version:
// - Template parameters specify topology at compile-time (e.g., <Item,
// WaitStrategy::Never, 1, 4, 2>)
// - Stage and thread indices are template parameters, validated at compile-time
// - Fixed-size arrays replace dynamic vectors
// - Specialized algorithms for each stage/thread combination
// - Type-safe guards prevent runtime indexing errors
//
// Usage Pattern:
// using Pipeline = StaticThreadPipeline<Item, WaitStrategy::WaitIfStageEmpty,
// 1, 4, 2>; Pipeline pipeline(lgSlotCount);
// ThreadPipeline<Item> pipeline(WaitStrategy::WaitIfStageEmpty, {1, 4, 2},
// lgSlotCount);
//
// // Producer threads (add items for stage 0 to consume):
// auto guard = pipeline.push(batchSize, /*block=*/true);
@@ -262,12 +274,54 @@ int check_producer_capacity(
// // Guard destructor publishes batch to stage 0 consumers
//
// // Stage worker threads (process items and pass to next stage):
// auto guard = pipeline.acquire<Stage, Thread>(maxBatch, /*may_block=*/true);
// auto guard = pipeline.acquire(stage, thread, maxBatch, /*may_block=*/true);
// for (auto& item : guard.batch) {
// // Process item
// }
// // Guard destructor marks items as consumed and available to next stage
//
// Multi-Thread Stage Processing:
// When a stage has multiple threads (e.g., {1, 1, 1, 2} = 2 threads in stage
// 3):
//
// OVERLAPPING BATCHES - EACH THREAD SEES EVERY ENTRY:
// - Multiple threads in the same stage get OVERLAPPING batches from the ring
// buffer
// - Thread 0: calls acquire(3, 0) - gets batch from ring positions 100-110
// - Thread 1: calls acquire(3, 1) - gets batch from ring positions 100-110
// (SAME)
// - Both threads see the same entries and must coordinate processing
//
// PARTITIONING STRATEGIES:
// Choose your partitioning approach based on your use case:
//
// 1. Ring buffer position-based partitioning:
// for (auto it = batch.begin(); it != batch.end(); ++it) {
// if (it.index() % 2 != thread_index) continue; // Skip entries for other
// threads process(*it); // Process only entries assigned to this thread
// }
//
// 2. Entry content-based partitioning:
// for (auto& item : guard.batch) {
// if (hash(item.connection_id) % 2 != thread_index) continue;
// process(item); // Process based on entry properties
// }
//
// 3. Process all entries (when each thread does different work):
// for (auto& item : guard.batch) {
// process(item); // Both threads process all items, but differently
// }
//
// Common Partitioning Patterns:
// - Position-based: it.index() % num_threads == thread_index
// - Hash-based: hash(item.key) % num_threads == thread_index
// - Type-based: item.type == MY_THREAD_TYPE
// - Load balancing: assign work based on thread load
// - All entries: each thread processes all items but performs different
// operations
//
// Note: it.index() returns the position in the ring buffer (0 to buffer_size-1)
//
// Memory Model:
// - Ring buffer size must be power of 2 for efficient masking
// - Actual ring slots accessed via: index & (slotCount - 1)
@@ -278,27 +332,27 @@ int check_producer_capacity(
// ordering
// - Uses C++20 atomic wait/notify for efficient blocking when no work available
// - RAII guards ensure proper cleanup even with exceptions
template <class T, WaitStrategy wait_strategy, int... ThreadsPerStage>
struct StaticThreadPipeline {
using Topology = StaticPipelineTopology<ThreadsPerStage...>;
template <class T> struct ThreadPipeline {
// Constructor
// wait_strategy: blocking behavior when no work is available
// threads_per_stage: number of threads in each stage (e.g., {1, 4, 2})
// lgSlotCount: log2 of ring buffer size (e.g., 10 -> 1024 slots)
// Template parameters specify pipeline topology (e.g., <Item, Never, 1, 4,
// 2>) Note: Producer threads are external to the pipeline and not counted in
// ThreadsPerStage
explicit StaticThreadPipeline(int lgSlotCount)
: slot_count(1 << lgSlotCount), slot_count_mask(slot_count - 1),
ring(slot_count) {
// Note: Producer threads are external to the pipeline and not counted in
// threads_per_stage
explicit ThreadPipeline(WaitStrategy wait_strategy,
std::vector<int> threads_per_stage, int lgSlotCount)
: wait_strategy_(wait_strategy), topology_(std::move(threads_per_stage)),
slot_count(1 << lgSlotCount), slot_count_mask(slot_count - 1),
ring(slot_count), all_threads(topology_.total_threads) {
// Otherwise we can't tell the difference between full and empty.
assert(!(slot_count_mask & 0x80000000));
initialize_all_threads();
}
StaticThreadPipeline(StaticThreadPipeline const &) = delete;
StaticThreadPipeline &operator=(StaticThreadPipeline const &) = delete;
StaticThreadPipeline(StaticThreadPipeline &&) = delete;
StaticThreadPipeline &operator=(StaticThreadPipeline &&) = delete;
ThreadPipeline(ThreadPipeline const &) = delete;
ThreadPipeline &operator=(ThreadPipeline const &) = delete;
ThreadPipeline(ThreadPipeline &&) = delete;
ThreadPipeline &operator=(ThreadPipeline &&) = delete;
struct Batch {
Batch() : ring(), begin_(), end_() {}
@@ -401,7 +455,7 @@ struct StaticThreadPipeline {
}
private:
friend struct StaticThreadPipeline;
friend struct ThreadPipeline;
Batch(std::vector<T> *const ring, uint32_t begin_, uint32_t end_)
: ring(ring), begin_(begin_), end_(end_) {}
std::vector<T> *const ring;
@@ -409,29 +463,29 @@ struct StaticThreadPipeline {
uint32_t end_;
};
// Static thread storage - fixed size array
std::array<ThreadState, Topology::total_threads> all_threads;
private:
WaitStrategy wait_strategy_;
PipelineTopology topology_;
alignas(128) std::atomic<uint32_t> slots{0};
alignas(128) std::atomic<uint32_t> pushes{0};
const uint32_t slot_count;
const uint32_t slot_count_mask;
std::vector<T> ring;
std::vector<ThreadState> all_threads;
void initialize_all_threads() {
[&]<std::size_t... StageIndices>(std::index_sequence<StageIndices...>) {
(init_stage_threads<StageIndices>(), ...);
}(std::make_index_sequence<Topology::num_stages>{});
for (int stage = 0; stage < topology_.num_stages; ++stage) {
init_stage_threads(stage);
}
}
template <int Stage> void init_stage_threads() {
constexpr int stage_offset = Topology::template stage_offset<Stage>();
constexpr int stage_thread_count = Topology::threads_per_stage[Stage];
constexpr int prev_stage_threads =
Topology::template prev_stage_thread_count<Stage>();
constexpr bool is_last_stage = (Stage == Topology::num_stages - 1);
void init_stage_threads(int stage) {
int stage_offset = topology_.stage_offset(stage);
int stage_thread_count = topology_.threads_per_stage[stage];
int prev_stage_threads = topology_.prev_stage_thread_count(stage);
bool is_last_stage = (stage == topology_.num_stages - 1);
for (int thread = 0; thread < stage_thread_count; ++thread) {
auto &thread_state = all_threads[stage_offset + thread];
@@ -440,14 +494,15 @@ private:
}
}
template <int Stage, int Thread>
Batch acquire_helper(uint32_t maxBatch, bool mayBlock) {
constexpr int thread_idx = Topology::template thread_index<Stage, Thread>();
Batch acquire_helper(int stage, int thread, uint32_t maxBatch,
bool may_block) {
int thread_idx = topology_.thread_index(stage, thread);
auto &thread_state = all_threads[thread_idx];
uint32_t begin = thread_state.local_pops & slot_count_mask;
uint32_t len = StaticPipelineAlgorithms::calculate_safe_len<
wait_strategy, Topology, Stage, Thread>(all_threads, pushes, mayBlock);
uint32_t len = PipelineAlgorithms::calculate_safe_len(
wait_strategy_, topology_, stage, thread, all_threads, pushes,
may_block);
if (maxBatch != 0) {
len = std::min(len, maxBatch);
@@ -462,13 +517,13 @@ private:
}
public:
template <int Stage, int Thread> struct StageGuard {
struct StageGuard {
Batch batch;
~StageGuard() {
if (!batch.empty()) {
StaticPipelineAlgorithms::update_thread_pops<wait_strategy, Topology,
Stage, Thread>(
PipelineAlgorithms::update_thread_pops(
pipeline->wait_strategy_, pipeline->topology_, stage, thread,
pipeline->all_threads, local_pops);
}
}
@@ -476,22 +531,28 @@ public:
StageGuard(StageGuard const &) = delete;
StageGuard &operator=(StageGuard const &) = delete;
StageGuard(StageGuard &&other) noexcept
: batch(other.batch), local_pops(other.local_pops),
: batch(other.batch), local_pops(other.local_pops), stage(other.stage),
thread(other.thread),
pipeline(std::exchange(other.pipeline, nullptr)) {}
StageGuard &operator=(StageGuard &&other) noexcept {
batch = other.batch;
local_pops = other.local_pops;
stage = other.stage;
thread = other.thread;
pipeline = std::exchange(other.pipeline, nullptr);
return *this;
}
private:
friend struct StaticThreadPipeline;
friend struct ThreadPipeline;
uint32_t local_pops;
StaticThreadPipeline *pipeline;
int stage;
int thread;
ThreadPipeline *pipeline;
StageGuard(Batch batch, uint32_t local_pops, StaticThreadPipeline *pipeline)
: batch(batch), local_pops(local_pops),
StageGuard(Batch batch, uint32_t local_pops, int stage, int thread,
ThreadPipeline *pipeline)
: batch(batch), local_pops(local_pops), stage(stage), thread(thread),
pipeline(batch.empty() ? nullptr : pipeline) {}
};
@@ -514,37 +575,30 @@ public:
}
private:
friend struct StaticThreadPipeline;
friend struct ThreadPipeline;
ProducerGuard() : batch(), tp() {}
ProducerGuard(Batch batch, StaticThreadPipeline *tp, uint32_t old_slot,
ProducerGuard(Batch batch, ThreadPipeline *tp, uint32_t old_slot,
uint32_t new_slot)
: batch(batch), tp(tp), old_slot(old_slot), new_slot(new_slot) {}
StaticThreadPipeline *const tp;
ThreadPipeline *const tp;
uint32_t old_slot;
uint32_t new_slot;
};
// Acquire a batch of items for processing by a consumer thread.
// Stage: which processing stage (0 = first consumer stage after producers) -
// compile-time parameter Thread: thread ID within the stage (0 to
// ThreadsPerStage[Stage]-1) - compile-time parameter maxBatch: maximum items
// to acquire (0 = no limit) may_block: whether to block waiting for items
// (false = return empty batch if none available) Returns: StageGuard<Stage,
// Thread> with batch of items to process and compile-time type safety
template <int Stage, int Thread>
[[nodiscard]] StageGuard<Stage, Thread> acquire(int maxBatch = 0,
// stage: which processing stage (0 = first consumer stage after producers)
// thread: thread ID within the stage (0 to threads_per_stage[stage]-1)
// maxBatch: maximum items to acquire (0 = no limit)
// may_block: whether to block waiting for items (false = return empty batch
// if none available) Returns: StageGuard with batch of items to process
[[nodiscard]] StageGuard acquire(int stage, int thread, int maxBatch = 0,
bool may_block = true) {
static_assert(Stage >= 0 && Stage < Topology::num_stages,
"Stage index out of bounds");
static_assert(Thread >= 0 && Thread < Topology::threads_per_stage[Stage],
"Thread index out of bounds");
auto batch = acquire_helper(stage, thread, maxBatch, may_block);
auto batch = acquire_helper<Stage, Thread>(maxBatch, may_block);
constexpr int thread_idx = Topology::template thread_index<Stage, Thread>();
int thread_idx = topology_.thread_index(stage, thread);
uint32_t local_pops = all_threads[thread_idx].local_pops;
return StageGuard<Stage, Thread>{std::move(batch), local_pops, this};
return StageGuard{std::move(batch), local_pops, stage, thread, this};
}
// Reserve slots in the ring buffer for a producer thread to fill with items.
@@ -577,9 +631,8 @@ public:
slot = slots.load(std::memory_order_relaxed);
begin = slot & slot_count_mask;
int capacity_result =
StaticPipelineAlgorithms::check_producer_capacity<Topology>(
all_threads, slot, size, slot_count, block);
int capacity_result = PipelineAlgorithms::check_producer_capacity(
topology_, all_threads, slot, size, slot_count, block);
if (capacity_result == 1) {
continue;
}
+226 -82
View File
@@ -5,28 +5,30 @@ This document describes the C++ coding style used in the WeaselDB project. These
## Table of Contents
1. [General Principles](#general-principles)
2. [Naming Conventions](#naming-conventions)
3. [File Organization](#file-organization)
4. [Code Structure](#code-structure)
5. [Memory Management](#memory-management)
6. [Error Handling](#error-handling)
7. [Documentation](#documentation)
8. [Testing](#testing)
1. [Naming Conventions](#naming-conventions)
1. [File Organization](#file-organization)
1. [Code Structure](#code-structure)
1. [Memory Management](#memory-management)
1. [Error Handling](#error-handling)
1. [Documentation](#documentation)
1. [Testing](#testing)
---
______________________________________________________________________
## General Principles
### Language Standard
- **C++20** is the target standard
- Use modern C++ features: RAII, move semantics, constexpr, concepts where appropriate
- Prefer standard library containers and algorithms over custom implementations
### C Library Functions and Headers
- **Always use std:: prefixed versions** of C library functions for consistency and clarity
- **Use C++ style headers** (`<cstring>`, `<cstdlib>`, etc.) instead of C style headers (`<string.h>`, `<stdlib.h>`, etc.)
- This applies to all standard libc functions: `std::abort()`, `std::fprintf()`, `std::free()`, `std::memcpy()`, `std::strlen()`, `std::strncpy()`, `std::memset()`, `std::signal()`, etc.
- **Exception:** Functions with no std:: equivalent (e.g., `perror()`, `gai_strerror()`) and system-specific headers (e.g., `<unistd.h>`, `<fcntl.h>`)
```cpp
// Preferred - C++ style
#include <cstring>
@@ -56,23 +58,25 @@ signal(SIGTERM, handler);
```
### Data Types
- **Almost always signed** - prefer `int`, `int64_t`, `ssize_t` over unsigned types except for:
- Bit manipulation operations
- Interfacing with APIs that require unsigned types
- Where defined unsigned overflow behavior (wraparound) is intentional and desired
- **Almost always auto** - let the compiler deduce types except when:
- The type is not obvious from context (prefer explicit for clarity)
- The type is not obvious from context and the exact type is important (prefer explicit for clarity)
- Specific type requirements matter (numeric conversions, template parameters)
- Interface contracts need explicit types (public APIs, function signatures)
- **Prefer uninitialized memory to default initialization** when using before initializing would be an error
- Valgrind will catch uninitialized memory usage bugs
- Avoid hiding logic errors with unnecessary zero-initialization
- Avoid hiding logic errors that Valgrind would have caught with unnecessary zero-initialization
- Default initialization can mask bugs and hurt performance
- **Floating point is for metrics only** - avoid `float`/`double` in core data structures and algorithms
- Use for performance measurements, statistics, and monitoring data
- Never use for counts, sizes, or business logic
- Avoid branching on the values of floats
### Type Casting
- **Never use C-style casts** - they're unsafe and can hide bugs by performing dangerous conversions
- **Use C++ cast operators** for explicit type conversions with clear intent and safety checks
- **Avoid `reinterpret_cast`** - almost always indicates poor design; redesign APIs instead
@@ -94,15 +98,42 @@ auto addr = reinterpret_cast<uintptr_t>(ptr); // Pointer to integer conv
```
### Performance Focus
- **Performance-first design** - optimize for the hot path
- **Simple is fast** - find exactly what's necessary, strip away everything else
- **Complexity must be justified with benchmarks** - measure performance impact before adding complexity
- **Strive for 0% CPU usage when idle** - avoid polling, busy waiting, or unnecessary background activity
- Use **inline functions** for performance-critical code (e.g., `allocate_raw`)
- **Zero-copy operations** with `std::string_view` over string copying
- **Arena allocation** for efficient memory management (~1ns vs ~20-270ns for malloc)
- **String views** with `std::string_view` to minimize unnecessary copying
- **Arena allocation** for efficient memory management, and to group related lifetimes together for simplicity
### String Formatting
- **Always use `format.hpp` functions** - formats directly into arena-allocated memory
- **Use `static_format()` for performance-sensitive code** - faster but less flexible than `format()`
- **Use `format()` function with arena allocator** for printf-style formatting
```cpp
// Most performance-sensitive - compile-time optimized concatenation
std::string_view response = static_format(arena,
"HTTP/1.1 ", status_code, " OK\r\n",
"Content-Length: ", body.size(), "\r\n",
"\r\n", body);
// Printf-style formatting - runtime flexible
Arena& arena = conn.get_arena();
std::string_view response = format(arena,
"HTTP/1.1 %d OK\r\n"
"Content-Length: %zu\r\n"
"\r\n%.*s",
status_code, body.size(),
static_cast<int>(body.size()), body.data());
```
- Offer APIs that let you avoid concatenating strings if possible - e.g. if the bytes are going to get written to a file descriptor you can skip concatenating and use scatter/gather writev-type calls.
### Complexity Control
- **Encapsulation is the main tool for controlling complexity**
- **Header files define the interface** - they are the contract with users of your code
- **Headers should be complete** - include everything needed to use the interface effectively:
@@ -111,15 +142,17 @@ auto addr = reinterpret_cast<uintptr_t>(ptr); // Pointer to integer conv
- Thread safety guarantees
- Performance characteristics
- Ownership and lifetime semantics
- **Do not rely on undocumented interface properties** - if it's not in the header, don't depend on it
- **Do not rely on undocumented properties of an interface** - if it's not in the header, don't depend on it
---
______________________________________________________________________
## Naming Conventions
### Variables and Functions
- **snake_case** for all variables, functions, and member functions
- **Legacy camelCase exists** - the codebase currently contains mixed naming due to historical development. New code should use snake_case. Existing camelCase should be converted to snake_case during natural refactoring (not mass renaming).
```cpp
int64_t used_bytes() const;
void add_block(int64_t size);
@@ -127,27 +160,31 @@ int32_t initial_block_size_;
```
### Classes and Structs
- **PascalCase** for class/struct names
- **Always use struct keyword** - eliminates debates about complexity and maintains consistency
- **Public members first, private after** - puts the interface users care about at the top, implementation details below
- **Full encapsulation still applies** - use `private:` sections to hide implementation details and maintain deep, capable structs
- The struct keyword doesn't mean shallow design - it means interface-first organization for human readers
- Omit the `public` keyword when inheriting from a struct. It's public by default. E.g. `struct A : B {};` instead of `struct A : public B {};`
```cpp
struct ArenaAllocator {
struct MyClass {
// Public interface first
explicit ArenaAllocator(int64_t initial_size = 1024);
void* allocate_raw(int64_t size);
void do_thing();
private:
// Private members after
int32_t initial_block_size_;
Block* current_block_;
int thing_count_;
};
```
### Enums
- **PascalCase** for enum class names
- **PascalCase** for enum values (not SCREAMING_SNAKE_CASE)
- C-style enums are acceptable where implicit int conversion is desirable, like for bitflags
```cpp
enum class Type {
PointRead,
@@ -162,14 +199,18 @@ enum class ParseState {
```
### Constants and Macros
- **snake_case** for constants
- Avoid macros when possible; prefer `constexpr` variables
```cpp
static const WeaselJsonCallbacks json_callbacks;
```
### Member Variables
- **Trailing underscore** for private member variables
```cpp
private:
int32_t initial_block_size_;
@@ -177,24 +218,28 @@ private:
```
### Template Parameters
- **PascalCase** for template type parameters
```cpp
template <typename T, typename... Args>
template <typename T> struct rebind { using type = T*; };
```
---
______________________________________________________________________
## File Organization
### Include Organization
- Use **`#pragma once`** instead of include guards
- **Never `using namespace std`** - always use fully qualified names for clarity and safety
- **Include order** (applies to both headers and source files):
1. Corresponding header file (for .cpp files only)
2. Standard library headers (alphabetical)
3. Third-party library headers
4. Project headers
1. Standard library headers (alphabetical)
1. Third-party library headers
1. Project headers
```cpp
#pragma once
@@ -207,7 +252,7 @@ template <typename T> struct rebind { using type = T*; };
#include <simdutf.h>
#include <weaseljson/weaseljson.h>
#include "arena_allocator.hpp"
#include "arena.hpp"
#include "commit_request.hpp"
// Never this:
@@ -218,25 +263,27 @@ std::vector<int> data;
std::unique_ptr<Parser> parser;
```
---
______________________________________________________________________
## Code Structure
### Class Design
- **Move-only semantics** for resource-owning types
- **Explicit constructors** to prevent implicit conversions
- **Delete copy operations** when inappropriate
- **Delete copy operations** when copying is inappropriate or should be discouraged
```cpp
struct ArenaAllocator {
explicit ArenaAllocator(int64_t initial_size = 1024);
struct Arena {
explicit Arena(int64_t initial_size = 1024);
// Copy construction is not allowed
ArenaAllocator(const ArenaAllocator &source) = delete;
ArenaAllocator &operator=(const ArenaAllocator &source) = delete;
Arena(const Arena &source) = delete;
Arena &operator=(const Arena &source) = delete;
// Move semantics
ArenaAllocator(ArenaAllocator &&source) noexcept;
ArenaAllocator &operator=(ArenaAllocator &&source) noexcept;
Arena(Arena &&source) noexcept;
Arena &operator=(Arena &&source) noexcept;
private:
int32_t initial_block_size_;
@@ -245,41 +292,46 @@ private:
```
### Function Design
- **Const correctness** - mark methods const when appropriate
- **Parameter passing:**
- Pass by value for types ≤ 16 bytes (int, pointers, string_view, small structs)
- Pass by const reference for types > 16 bytes (containers, large objects)
- **Return by value** for small types (≤ 16 bytes), **string_view** for zero-copy over strings
- **Return by value** for small types (≤ 16 bytes), **string_view** to avoid copying strings
- **noexcept specification** for move operations and non-throwing functions
```cpp
std::span<const Operation> operations() const { return operations_; }
void process_data(std::string_view request_data); // ≤ 16 bytes, pass by value
void process_request(const CommitRequest& commit_request); // > 16 bytes, pass by reference
ArenaAllocator(ArenaAllocator &&source) noexcept;
Arena(Arena &&source) noexcept;
```
### Template Usage
- **Template constraints** using static_assert for better error messages
- **SFINAE** or concepts for template specialization
### Factory Patterns & Ownership
- **Static factory methods** for complex construction requiring shared ownership
- **Static factory methods** for complex construction requirements like enforcing shared ownership
- **Friend-based factories** for access control when constructor should be private
- **Ownership guidelines:**
- **unique_ptr** for exclusive ownership (most common case)
- **shared_ptr** only when multiple owners need concurrent access to same object
- **Ref** only when object logically has multiple owners (`Ref` is our custom std::shared_ptr variant)
- **Factory methods return appropriate smart pointer type** based on ownership needs
```cpp
// Shared ownership - multiple components need concurrent access
auto server = Server::create(config, handler); // Returns shared_ptr
auto server = Server::create(config, handler); // Returns Ref<Server>
// Exclusive ownership - single owner, transfer via move
auto connection = Connection::createForServer(addr, fd, connection_id, handler, server_ref);
// Friend-based factory for access control
struct Connection {
void append_message(std::string_view message_data);
WeakRef<MessageSender> get_weak_ref() const;
private:
Connection(struct sockaddr_storage client_addr, int file_descriptor,
int64_t connection_id, ConnectionHandler* request_handler,
@@ -289,8 +341,10 @@ private:
```
### Control Flow
- **Early returns** to reduce nesting
- **Range-based for loops** when possible
```cpp
if (size == 0) {
return nullptr;
@@ -301,28 +355,76 @@ for (auto &precondition : preconditions_) {
}
```
---
### Atomic Operations
- **Never use assignment operators** with `std::atomic` - always use explicit `store()` and `load()`
- **Always specify memory ordering** explicitly for atomic operations
- **Use the least restrictive correct memory ordering** - choose the weakest ordering that maintains correctness
```cpp
// Preferred - explicit store/load with precise memory ordering
std::atomic<uint64_t> counter;
counter.store(42, std::memory_order_relaxed); // Single-writer metric updates
auto value = counter.load(std::memory_order_relaxed); // Reading metrics for display
counter.store(1, std::memory_order_release); // Publishing initialization
auto ready = counter.load(std::memory_order_acquire); // Synchronizing with publisher
counter.store(42, std::memory_order_seq_cst); // When sequential consistency needed
// Avoid - assignment operators (implicit memory ordering)
std::atomic<uint64_t> counter;
counter = 42; // Implicit - memory ordering not explicit
auto value = counter; // Implicit - memory ordering not explicit
```
______________________________________________________________________
## Memory Management
### Ownership & Allocation
- **Arena allocators** for request-scoped memory with **STL allocator adapters** (see Performance Focus section for characteristics)
- **String views** pointing to arena-allocated memory for zero-copy operations
- **Arena** for request-scoped memory with **STL allocator adapters**
- **String views** pointing to arena-allocated memory to avoid unnecessary copying
- **STL containers with arena allocators require default construction after arena reset** - `clear()` is not sufficient
```cpp
// STL containers with arena allocators - correct reset pattern
std::vector<Operation, ArenaStlAllocator<Operation>> operations(arena_allocator);
std::vector<Operation, ArenaStlAllocator<Operation>> operations(arena);
// ... use container ...
operations = {}; // Default construct - clear() won't work correctly
arena_allocator.reset(); // Reset arena memory
arena.reset(); // Reset arena memory
```
### Arena String Copying
- **Always use `Arena::copy_string()`** for copying string data into arena memory
- **Avoid manual allocation and memcpy** for string copying
- **Use `Arena::allocate_span<T>()`** for array allocations instead of manual span construction
```cpp
// Preferred - unified arena methods
std::string_view copy = arena.copy_string(original_string);
auto buffer = arena.allocate_span<char>(1024);
auto strings = arena.allocate_span<std::string_view>(count);
// Avoid - manual allocation and copying
char *copied = arena.allocate<char>(str.size());
std::memcpy(copied, str.data(), str.size());
std::string_view copy(copied, str.size());
// Avoid - manual span construction
auto span = std::span{arena.allocate<std::string_view>(count), count};
```
### Resource Management
- **RAII** everywhere - constructors acquire, destructors release
- **Move semantics** for efficient resource transfer
- **Explicit cleanup** methods where appropriate
```cpp
~ArenaAllocator() {
~Arena() {
while (current_block_) {
Block *prev = current_block_->prev;
std::free(current_block_);
@@ -331,20 +433,22 @@ arena_allocator.reset(); // Reset arena memory
}
```
---
______________________________________________________________________
## Error Handling
### Error Classification & Response
- **Expected errors** (invalid input, timeouts): Return error codes for programmatic handling
- **System failures** (malloc fail, socket fail): Abort immediately with error message
- **Programming errors** (precondition violations, assertions): Abort immediately
### Error Contract Design
- **Error codes are the API contract** - use enums for programmatic decisions
- **Error messages are human-readable only** - never parse message strings
- **Consistent error boundaries** - each component defines what it can/cannot recover from
- **Interface precondition violations are undefined behavior** - acceptable to skip checks for performance in hot paths
- **Interface precondition violations are undefined behavior** - it's acceptable to skip checks for performance in hot paths
- **Error code types must be nodiscard** - mark error code enums with `[[nodiscard]]` to prevent silent failures
```cpp
@@ -353,16 +457,17 @@ enum class [[nodiscard]] ParseResult { Success, InvalidJson, MissingField };
// System failure - abort immediately
void* memory = std::malloc(size);
if (!memory) {
std::fprintf(stderr, "ArenaAllocator: Memory allocation failed\n");
std::fprintf(stderr, "Arena: Memory allocation failed\n");
std::abort();
}
// ... use memory, eventually std::free(memory)
// Programming error - precondition violation (may be omitted for performance)
// Programming error - precondition violation (gets compiled out in release builds)
assert(ptr != nullptr && "Precondition violated: pointer must be non-null");
```
### Assertions
- **Programming error detection** using standard `assert()` macro
- **Assertion behavior follows C++ standards:**
- **Debug builds**: Assertions active (undefined `NDEBUG`)
@@ -371,6 +476,7 @@ assert(ptr != nullptr && "Precondition violated: pointer must be non-null");
- **Static assertions** for compile-time validation (always active)
**Usage guidelines:**
- Use for programming errors: null checks, precondition validation, invariants
- Don't use for expected runtime errors: use return codes instead
@@ -426,26 +532,28 @@ if (result == -1 && errno != EINTR) {
Most system calls are not interruptible in practice. For these, it is not necessary to add a retry loop. This includes:
* `fcntl` (with `F_GETFL`, `F_SETFL`, `F_GETFD`, `F_SETFD` - note: `F_SETLKW` and `F_OFD_SETLKW` CAN return EINTR)
* `epoll_ctl`
* `socketpair`
* `pipe`
* `setsockopt`
* `epoll_create1`
* `close` (special case: guaranteed closed even on EINTR on Linux)
- `fcntl` (with `F_GETFL`, `F_SETFL`, `F_GETFD`, `F_SETFD` - note: `F_SETLKW` and `F_OFD_SETLKW` CAN return EINTR)
- `epoll_ctl`
- `socketpair`
- `pipe`
- `setsockopt`
- `epoll_create1`
- `close` (special case: guaranteed closed even on EINTR on Linux)
When in doubt, consult the `man` page for the specific system call to see if it can return `EINTR`.
---
______________________________________________________________________
## Documentation
### Doxygen Style
- **/** for struct and public method documentation
- **@brief** for short descriptions
- **@param** and **@return** for function parameters
- **@note** for important implementation notes
- **@warning** for critical usage warnings
```cpp
/**
* @brief Type-safe version of realloc_raw for arrays of type T.
@@ -460,9 +568,11 @@ T *realloc(T *existing_ptr, int32_t current_size, int32_t requested_size);
```
### Code Comments
- **Explain why, not what** - code should be self-documenting
- **Explain why, not what** - *what* the code does should be clear without any comments
- **Performance notes** for optimization decisions
- **Thread safety** and ownership semantics
```cpp
// Uses O(1) accumulated counters for fast retrieval
int64_t total_allocated() const;
@@ -472,23 +582,26 @@ Connection(struct sockaddr_storage addr, int fd, int64_t id,
ConnectionHandler *handler, std::weak_ptr<Server> server);
```
---
______________________________________________________________________
## Testing
### Test Framework
- **doctest** for unit testing
- **TEST_CASE** and **SUBCASE** for test organization
- **CHECK** for assertions (non-terminating)
- **REQUIRE** for critical assertions (terminating)
### Test Structure
- **Descriptive test names** explaining the scenario
- **SUBCASE** for related test variations
- **SUBCASE** for related test variations that share setup/teardown code
- **Fresh instances** for each test to avoid state contamination
```cpp
TEST_CASE("ArenaAllocator basic allocation") {
ArenaAllocator arena;
TEST_CASE("Arena basic allocation") {
Arena arena;
SUBCASE("allocate zero bytes returns nullptr") {
void *ptr = arena.allocate_raw(0);
@@ -504,34 +617,27 @@ TEST_CASE("ArenaAllocator basic allocation") {
```
### Test Design Principles
- **Test the contract, not the implementation** - validate what the API promises to deliver, not implementation details
- **Both integration and unit tests** - test components in isolation and working together
- **Prefer fakes to mocks** - use real implementations for internal components, fake external dependencies
- **Always enable assertions in tests** - use `-UNDEBUG` pattern to ensure assertions are checked (see Build Integration section)
TODO make a new example here using APIs that exist
```cpp
// Good: Testing through public API
TEST_CASE("Server accepts connections") {
auto config = Config::defaultConfig();
auto handler = std::make_unique<TestHandler>();
auto server = Server::create(config, std::move(handler));
// Test observable behavior - server can accept connections
auto result = connectToServer(server->getPort());
CHECK(result.connected);
}
// Avoid: Testing internal implementation details
// TEST_CASE("Server creates epoll instance") { /* implementation detail */ }
```
### What NOT to Test
**Avoid testing language features and plumbing:**
**Avoid testing language features:**
- Don't test that virtual functions dispatch correctly
- Don't test that standard library types work (unique_ptr, containers, etc.)
- Don't test basic constructor/destructor calls
**Test business logic instead:**
- When does your code call hooks/callbacks and why?
- What state transitions trigger behavior changes?
- How does your code handle error conditions?
@@ -540,6 +646,7 @@ TEST_CASE("Server accepts connections") {
**Ask: "Am I testing the C++ compiler or my application logic?"**
### Test Synchronization (Authoritative Rules)
- **ABSOLUTELY NEVER use timeouts** (`sleep_for`, `wait_for`, etc.)
- **Deterministic synchronization only:**
- Blocking I/O (naturally waits for completion)
@@ -547,29 +654,63 @@ TEST_CASE("Server accepts connections") {
- `std::latch`, `std::barrier`, futures/promises
- **Force concurrent execution** using `std::latch` to synchronize thread startup
#### Threading Checklist for Tests/Benchmarks
**Common threading principles (all concurrent code):**
- **Count total threads** - Include main/benchmark thread in count
- **Always assume concurrent execution needed** - Tests/benchmarks require real concurrency
- **Add synchronization primitive** - `std::latch start_latch{N}` (most common), `std::barrier`, or similar where N = total concurrent threads
- **Each thread synchronizes before doing work** - e.g., `start_latch.arrive_and_wait()` or `barrier.arrive_and_wait()`
- **Main thread synchronizes before measurement/execution** - ensures all threads start simultaneously
**Test-specific:**
- **Perform many operations per thread creation** - amortize thread creation cost and increase chances of hitting race conditions
- **Pattern: Create test that spawns threads and runs many operations, then run that test many times** - amortizes thread creation cost while providing fresh test instances
- **Run 100-10000 operations per test, and 100-10000 test iterations** - maximizes chances of hitting race conditions
- **Always run with ThreadSanitizer** - compile with `-fsanitize=thread`
**Benchmark-specific:**
- **NEVER create threads inside the benchmark measurement** - creates thread creation/destruction overhead, not contention
- **Create background threads OUTSIDE the benchmark** that run continuously during measurement
- **Use `std::atomic<bool> keep_running` to cleanly shut down background threads after benchmark**
- **Measure only the foreground operation under real contention from background threads**
**Red flags to catch immediately:**
- ❌ Creating threads in a loop without `std::latch`
- ❌ Background threads starting work immediately
- ❌ Benchmark measuring before all threads synchronized
- ❌ Any use of `sleep_for`, `wait_for`, or timeouts
**Simple rule:** Multiple threads = `std::latch` synchronization. No exceptions, even for "simple" background threads.
```cpp
// BAD: Race likely over before threads start
std::atomic<int> counter{0};
int counter = 0;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&]() { counter++; }); // Probably sequential
}
// GOOD: Force threads to race simultaneously
std::atomic<int> counter{0};
int counter = 0;
std::latch start_latch{4};
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&]() {
start_latch.count_down_and_wait(); // All threads start together
counter++; // Now they actually race
counter++; // Now they actually race (data race on non-atomic)
});
}
```
---
______________________________________________________________________
## Build Integration
### Build Configuration
```bash
# Debug: assertions on, optimizations off
cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
@@ -579,6 +720,7 @@ cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
```
**Test Target Pattern:**
- Production targets follow build type (assertions off in Release)
- Test targets use `-UNDEBUG` to force assertions on in all builds
- Ensures consistent test validation regardless of build type
@@ -586,8 +728,9 @@ cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
```cmake
# Test target with assertions always enabled
add_executable(test_example tests/test_example.cpp src/example.cpp)
target_link_libraries(test_example doctest::doctest)
target_link_libraries(test_example doctest_impl)
target_compile_options(test_example PRIVATE -UNDEBUG) # Always enable assertions
add_test(NAME test_example COMMAND test_example)
# Production target follows build type
add_executable(example src/example.cpp src/main.cpp)
@@ -595,4 +738,5 @@ add_executable(example src/example.cpp src/main.cpp)
```
### Code Generation
- Generated files go in build directory, not source
+32
View File
@@ -0,0 +1,32 @@
# WeaselDB Test Configuration with Benchmark Health Check
[server]
# Network interfaces to listen on - both TCP for external access and Unix socket for high-performance local testing
interfaces = [
{ type = "tcp", address = "0.0.0.0", port = 8123 },
{ type = "unix", path = "weaseldb.sock" }
]
# Maximum request size in bytes (for 413 Content Too Large responses)
max_request_size_bytes = 1048576 # 1MB
# Number of I/O threads for handling connections and network events
io_threads = 8
# Event batch size for epoll processing
event_batch_size = 128
[commit]
# Minimum length for request_id to ensure sufficient entropy
min_request_id_length = 20
# How long to retain request IDs for /v1/status queries (hours)
request_id_retention_hours = 24
# Minimum number of versions to retain request IDs
request_id_retention_versions = 100000000
[subscription]
# Maximum buffer size for unconsumed data in /v1/subscribe (bytes)
max_buffer_size_bytes = 10485760 # 10MB
# Interval for sending keepalive comments to prevent idle timeouts (seconds)
keepalive_interval_seconds = 30
[benchmark]
# Use original benchmark load for testing
ok_resolve_iterations = 4000
+8 -3
View File
@@ -1,9 +1,11 @@
# WeaselDB Configuration File
[server]
unix_socket_path = "weaseldb.sock"
bind_address = "127.0.0.1"
port = 8080
# Network interfaces to listen on - both TCP for external access and Unix socket for high-performance local testing
interfaces = [
{ type = "tcp", address = "127.0.0.1", port = 8080 },
{ type = "unix", path = "weaseldb.sock" }
]
# Maximum request size in bytes (for 413 Content Too Large responses)
max_request_size_bytes = 1048576 # 1MB
# Number of I/O threads for handling connections and network events
@@ -25,3 +27,6 @@ request_id_retention_versions = 100000000
max_buffer_size_bytes = 10485760 # 10MB
# Interval for sending keepalive comments to prevent idle timeouts (seconds)
keepalive_interval_seconds = 30
[benchmark]
ok_resolve_iterations = 4000
+554
View File
@@ -0,0 +1,554 @@
#include <doctest/doctest.h>
#include <cstring>
#include <string>
#include "api_url_parser.hpp"
// Helper to convert string to mutable buffer for testing
std::string make_mutable_copy(const std::string &url) {
return url; // Return copy that can be modified
}
TEST_CASE("ApiUrlParser routing") {
SUBCASE("Static GET routes") {
auto url = make_mutable_copy("/v1/version");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetVersion);
url = make_mutable_copy("/v1/subscribe");
result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetSubscribe);
url = make_mutable_copy("/metrics");
result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetMetrics);
url = make_mutable_copy("/ok");
result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetOk);
}
SUBCASE("Static POST routes") {
auto url = make_mutable_copy("/v1/commit");
RouteMatch match;
auto result = ApiUrlParser::parse("POST", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::PostCommit);
}
SUBCASE("Not found") {
auto url = make_mutable_copy("/unknown/route");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::NotFound);
url = make_mutable_copy("/v1/version");
result = ApiUrlParser::parse("DELETE", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::NotFound);
}
}
TEST_CASE("ApiUrlParser with query strings") {
SUBCASE("Simple query string") {
auto url = make_mutable_copy("/v1/status?request_id=123");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"123");
}
SUBCASE("Multiple query parameters") {
auto url = make_mutable_copy("/v1/status?request_id=abc&min_version=42");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"abc");
REQUIRE(match.params[static_cast<int>(ApiParameterKey::MinVersion)]
.has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::MinVersion)].value() ==
"42");
}
SUBCASE("Unknown parameters are ignored") {
auto url = make_mutable_copy("/v1/version?foo=bar&baz=quux");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetVersion);
CHECK_FALSE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
}
}
TEST_CASE("ApiUrlParser with URL parameters") {
SUBCASE("PUT retention policy") {
auto url = make_mutable_copy("/v1/retention/my-policy");
RouteMatch match;
auto result = ApiUrlParser::parse("PUT", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::PutRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"my-policy");
}
SUBCASE("DELETE retention policy") {
auto url = make_mutable_copy("/v1/retention/another-policy");
RouteMatch match;
auto result = ApiUrlParser::parse("DELETE", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::DeleteRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"another-policy");
}
SUBCASE("GET retention policy") {
auto url = make_mutable_copy("/v1/retention/get-this");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"get-this");
}
SUBCASE("GET all retention policies (no ID)") {
auto url = make_mutable_copy("/v1/retention");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
CHECK_FALSE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
}
}
TEST_CASE("ApiUrlParser with URL and query parameters") {
auto url = make_mutable_copy("/v1/retention/p1?request_id=abc123");
RouteMatch match;
auto result = ApiUrlParser::parse("DELETE", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::DeleteRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"p1");
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"abc123");
}
TEST_CASE("ApiUrlParser URL decoding") {
SUBCASE("Path segment percent-decoding") {
auto url = make_mutable_copy("/v1/retention/my%2Dpolicy");
RouteMatch match;
auto result = ApiUrlParser::parse("PUT", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::PutRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"my-policy");
}
SUBCASE("Query parameter form decoding (+ to space)") {
auto url = make_mutable_copy("/v1/status?request_id=hello+world");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"hello world");
}
SUBCASE("Query parameter percent-decoding") {
auto url = make_mutable_copy("/v1/status?request_id=hello%20world");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"hello world");
}
SUBCASE("Base64-like sequences in query parameters") {
auto url = make_mutable_copy("/v1/status?request_id=YWJj%3D");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"YWJj=");
}
SUBCASE("Mixed encoding in path and query") {
auto url = make_mutable_copy(
"/v1/retention/my%2Dpolicy?request_id=hello+world%21");
RouteMatch match;
auto result = ApiUrlParser::parse("DELETE", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::DeleteRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"my-policy");
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"hello world!");
}
}
TEST_CASE("ApiUrlParser malformed encoding") {
SUBCASE("Incomplete percent sequence in path") {
auto url = make_mutable_copy("/v1/retention/bad%2");
RouteMatch match;
auto result = ApiUrlParser::parse("PUT", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::MalformedEncoding);
}
SUBCASE("Invalid hex digits in path") {
auto url = make_mutable_copy("/v1/retention/bad%ZZ");
RouteMatch match;
auto result = ApiUrlParser::parse("PUT", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::MalformedEncoding);
}
SUBCASE("Incomplete percent sequence in query") {
auto url = make_mutable_copy("/v1/status?request_id=bad%2");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::MalformedEncoding);
}
SUBCASE("Invalid hex digits in query") {
auto url = make_mutable_copy("/v1/status?request_id=bad%GG");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::MalformedEncoding);
}
SUBCASE("Percent at end of path") {
auto url = make_mutable_copy("/v1/retention/bad%");
RouteMatch match;
auto result = ApiUrlParser::parse("PUT", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::MalformedEncoding);
}
SUBCASE("Percent at end of query") {
auto url = make_mutable_copy("/v1/status?request_id=bad%");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::MalformedEncoding);
}
}
TEST_CASE("ApiUrlParser edge cases and bugs") {
SUBCASE("Bug: Path boundary error - /v1/retention/ with trailing slash") {
// BUG: Code checks length > 13 but substrings at 14, causing off-by-one
auto url = make_mutable_copy(
"/v1/retention/"); // length 14, exactly the boundary case
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
// This should NOT set PolicyId since it's empty, but current code might
// have issues
CHECK_FALSE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
}
SUBCASE("Bug: Empty URL handling") {
auto url = make_mutable_copy("");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::NotFound);
}
SUBCASE("Bug: Query-only URL") {
auto url = make_mutable_copy("?request_id=123");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::NotFound);
// Should still parse query parameters
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"123");
}
SUBCASE("Bug: Consecutive delimiters in query string") {
auto url =
make_mutable_copy("/v1/status?&&request_id=123&&min_version=42&&");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"123");
REQUIRE(match.params[static_cast<int>(ApiParameterKey::MinVersion)]
.has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::MinVersion)].value() ==
"42");
}
SUBCASE("Bug: Parameter without value (should be skipped)") {
auto url = make_mutable_copy("/v1/status?debug&request_id=123");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
// debug parameter should be ignored since it has no value
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"123");
}
SUBCASE("Bug: Empty parameter value") {
auto url = make_mutable_copy("/v1/status?request_id=");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"");
}
SUBCASE("Edge: Exact length boundary for retention path") {
// Test the exact boundary condition: "/v1/retention" is 13 chars
auto url = make_mutable_copy("/v1/retention"); // exactly 13 characters
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
CHECK_FALSE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
}
SUBCASE("Edge: Minimum valid policy ID") {
// Test one character after the boundary
auto url =
make_mutable_copy("/v1/retention/a"); // 15 chars total, policy_id = "a"
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
"a");
}
}
TEST_CASE("ApiUrlParser specific bug reproduction") {
SUBCASE("Reproduction: Path boundary math error") {
// The bug: code checks length > 13 but substrings at 14
// "/v1/retention" = 13 chars, "/v1/retention/" = 14 chars
// This test should demonstrate undefined behavior or wrong results
auto url = make_mutable_copy("/v1/retention/");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
// Expected behavior: should match GetRetention but NOT set PolicyId
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
// The bug: may incorrectly extract empty string or cause buffer read error
// Let's see what actually happens
if (match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value()) {
auto policy_id =
match.params[static_cast<int>(ApiParameterKey::PolicyId)].value();
CHECK(policy_id.empty()); // Should be empty if set at all
}
}
SUBCASE("Reproduction: Query parsing with edge cases") {
// Test parameter parsing with multiple edge conditions
auto url = make_mutable_copy(
"/v1/status?=empty_key&no_value&request_id=&min_version=42&=");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
// Should handle empty values correctly
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"");
REQUIRE(match.params[static_cast<int>(ApiParameterKey::MinVersion)]
.has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::MinVersion)].value() ==
"42");
}
SUBCASE("Reproduction: Very long input stress test") {
// Test potential integer overflow or performance issues
std::string long_policy_id(1000, 'x'); // 1000 character policy ID
auto url = make_mutable_copy("/v1/retention/" + long_policy_id +
"?request_id=" + std::string(500, 'y'));
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)].value() ==
long_policy_id);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
std::string(500, 'y'));
}
SUBCASE("Reproduction: Zero-length edge case") {
char empty_buffer[1] = {0};
RouteMatch match;
auto result = ApiUrlParser::parse("GET", empty_buffer, 0, match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::NotFound);
}
SUBCASE("Reproduction: Null buffer edge case") {
// This might cause undefined behavior if not handled properly
RouteMatch match;
char single_char = '/';
auto result = ApiUrlParser::parse("GET", &single_char, 1, match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::NotFound);
}
SUBCASE("BUG: Query parser pos increment overflow") {
// BUG: pos += pair_end + 1 can go beyond buffer bounds
// When pair_end == query_length - pos (no & found), pos becomes
// query_length + 1
auto url = make_mutable_copy("/v1/status?no_ampersand_at_end");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
// This should not crash or have undefined behavior
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetStatus);
// Parameter should be ignored since it's not a known key
}
SUBCASE("BUG: String view with potentially negative length cast") {
// BUG: decoded_value_length is int but gets cast to size_t for string_view
// If decode function returned negative (which it can), this could wrap
// around
// We can't easily trigger the decode function to return -1 through normal
// parsing since that's caught earlier, but this tests the edge case
// handling
auto url = make_mutable_copy("/v1/status?request_id=normal_value");
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
REQUIRE(
match.params[static_cast<int>(ApiParameterKey::RequestId)].has_value());
CHECK(match.params[static_cast<int>(ApiParameterKey::RequestId)].value() ==
"normal_value");
}
SUBCASE("BUG: Array bounds - PolicyId path extraction edge case") {
// Test the boundary condition more precisely
// "/v1/retention" = 13 chars, checking length > 13, substr(14)
auto url = make_mutable_copy("/v1/retention/"); // exactly 14 chars
RouteMatch match;
auto result = ApiUrlParser::parse("GET", url.data(),
static_cast<int>(url.size()), match);
CHECK(result == ParseResult::Success);
CHECK(match.route == HttpRoute::GetRetention);
// path.length() = 14, so > 13 is true
// path.substr(14) should return empty string_view
// The bug would be if this crashes or returns invalid data
if (match.params[static_cast<int>(ApiParameterKey::PolicyId)].has_value()) {
CHECK(match.params[static_cast<int>(ApiParameterKey::PolicyId)]
.value()
.empty());
}
}
}
@@ -1,26 +1,27 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "arena_allocator.hpp"
#include "arena.hpp"
#include "format.hpp"
#include <cstring>
#include <doctest/doctest.h>
#include <string>
#include <vector>
TEST_CASE("ArenaAllocator basic construction") {
ArenaAllocator arena;
TEST_CASE("Arena basic construction") {
Arena arena;
CHECK(arena.num_blocks() == 0);
CHECK(arena.used_bytes() == 0);
CHECK(arena.total_allocated() == 0);
CHECK(arena.available_in_current_block() == 0);
}
TEST_CASE("ArenaAllocator custom initial size") {
ArenaAllocator arena(2048);
TEST_CASE("Arena custom initial size") {
Arena arena(2048);
CHECK(arena.num_blocks() == 0);
CHECK(arena.total_allocated() == 0);
CHECK(arena.available_in_current_block() == 0);
}
TEST_CASE("ArenaAllocator basic allocation") {
ArenaAllocator arena;
TEST_CASE("Arena basic allocation") {
Arena arena;
SUBCASE("allocate zero bytes returns nullptr") {
void *ptr = arena.allocate_raw(0);
@@ -45,8 +46,8 @@ TEST_CASE("ArenaAllocator basic allocation") {
}
}
TEST_CASE("ArenaAllocator alignment") {
ArenaAllocator arena;
TEST_CASE("Arena alignment") {
Arena arena;
SUBCASE("default alignment") {
void *ptr = arena.allocate_raw(1);
@@ -65,14 +66,14 @@ TEST_CASE("ArenaAllocator alignment") {
}
SUBCASE("alignment with larger allocations") {
ArenaAllocator fresh_arena;
Arena fresh_arena;
void *ptr = fresh_arena.allocate_raw(100, 64);
CHECK(reinterpret_cast<uintptr_t>(ptr) % 64 == 0);
}
}
TEST_CASE("ArenaAllocator block management") {
ArenaAllocator arena(128);
TEST_CASE("Arena block management") {
Arena arena(128);
SUBCASE("single block allocation") {
void *ptr = arena.allocate_raw(64);
@@ -97,8 +98,8 @@ TEST_CASE("ArenaAllocator block management") {
}
}
TEST_CASE("ArenaAllocator construct template") {
ArenaAllocator arena;
TEST_CASE("Arena construct template") {
Arena arena;
SUBCASE("construct int") {
int *ptr = arena.construct<int>(42);
@@ -141,8 +142,8 @@ TEST_CASE("ArenaAllocator construct template") {
}
}
TEST_CASE("ArenaAllocator reset functionality") {
ArenaAllocator arena;
TEST_CASE("Arena reset functionality") {
Arena arena;
arena.allocate_raw(100);
arena.allocate_raw(200);
@@ -158,8 +159,8 @@ TEST_CASE("ArenaAllocator reset functionality") {
CHECK(arena.used_bytes() == 50);
}
TEST_CASE("ArenaAllocator reset memory leak test") {
ArenaAllocator arena(32); // Smaller initial size
TEST_CASE("Arena reset memory leak test") {
Arena arena(32); // Smaller initial size
// Force multiple blocks
arena.allocate_raw(30); // First block (32 bytes)
@@ -190,8 +191,8 @@ TEST_CASE("ArenaAllocator reset memory leak test") {
CHECK(arena.used_bytes() == 20);
}
TEST_CASE("ArenaAllocator memory tracking") {
ArenaAllocator arena(512);
TEST_CASE("Arena memory tracking") {
Arena arena(512);
CHECK(arena.total_allocated() == 0);
CHECK(arena.used_bytes() == 0);
@@ -209,8 +210,8 @@ TEST_CASE("ArenaAllocator memory tracking") {
CHECK(arena.total_allocated() >= 1024);
}
TEST_CASE("ArenaAllocator stress test") {
ArenaAllocator arena(1024);
TEST_CASE("Arena stress test") {
Arena arena(1024);
SUBCASE("many small allocations") {
std::vector<void *> ptrs;
@@ -236,13 +237,13 @@ TEST_CASE("ArenaAllocator stress test") {
}
}
TEST_CASE("ArenaAllocator move semantics") {
ArenaAllocator arena1(512);
TEST_CASE("Arena move semantics") {
Arena arena1(512);
arena1.allocate_raw(100);
size_t used_bytes = arena1.used_bytes();
size_t num_blocks = arena1.num_blocks();
ArenaAllocator arena2 = std::move(arena1);
Arena arena2 = std::move(arena1);
CHECK(arena2.used_bytes() == used_bytes);
CHECK(arena2.num_blocks() == num_blocks);
@@ -250,16 +251,16 @@ TEST_CASE("ArenaAllocator move semantics") {
CHECK(ptr != nullptr);
}
TEST_CASE("ArenaAllocator edge cases") {
TEST_CASE("Arena edge cases") {
SUBCASE("very small block size") {
ArenaAllocator arena(16);
Arena arena(16);
void *ptr = arena.allocate_raw(8);
CHECK(ptr != nullptr);
CHECK(arena.num_blocks() == 1);
}
SUBCASE("allocation exactly block size") {
ArenaAllocator arena(64);
Arena arena(64);
void *ptr = arena.allocate_raw(64);
CHECK(ptr != nullptr);
CHECK(arena.num_blocks() == 1);
@@ -270,7 +271,7 @@ TEST_CASE("ArenaAllocator edge cases") {
}
SUBCASE("multiple resets") {
ArenaAllocator arena;
Arena arena;
for (int i = 0; i < 10; ++i) {
arena.allocate_raw(100);
arena.reset();
@@ -289,8 +290,8 @@ struct TestPOD {
}
};
TEST_CASE("ArenaAllocator with custom objects") {
ArenaAllocator arena;
TEST_CASE("Arena with custom objects") {
Arena arena;
TestPOD *obj1 = arena.construct<TestPOD>(42, "first");
TestPOD *obj2 = arena.construct<TestPOD>(84, "second");
@@ -304,8 +305,8 @@ TEST_CASE("ArenaAllocator with custom objects") {
CHECK(std::strcmp(obj2->name, "second") == 0);
}
TEST_CASE("ArenaAllocator geometric growth policy") {
ArenaAllocator arena(64);
TEST_CASE("Arena geometric growth policy") {
Arena arena(64);
SUBCASE("normal geometric growth doubles size") {
arena.allocate_raw(60); // Fill first block
@@ -337,8 +338,8 @@ TEST_CASE("ArenaAllocator geometric growth policy") {
}
}
TEST_CASE("ArenaAllocator alignment edge cases") {
ArenaAllocator arena;
TEST_CASE("Arena alignment edge cases") {
Arena arena;
SUBCASE("unaligned then aligned allocation") {
void *ptr1 = arena.allocate_raw(1, 1);
@@ -350,20 +351,20 @@ TEST_CASE("ArenaAllocator alignment edge cases") {
}
SUBCASE("large alignment requirements") {
ArenaAllocator fresh_arena;
Arena fresh_arena;
void *ptr = fresh_arena.allocate_raw(1, 128);
CHECK(ptr != nullptr);
CHECK(reinterpret_cast<uintptr_t>(ptr) % 128 == 0);
}
}
TEST_CASE("ArenaAllocator realloc functionality") {
ArenaAllocator arena;
TEST_CASE("Arena realloc functionality") {
Arena arena;
SUBCASE("realloc edge cases") {
// realloc with new_size == 0 returns nullptr and reclaims memory if it's
// the last allocation
ArenaAllocator fresh_arena(256);
Arena fresh_arena(256);
void *ptr = fresh_arena.allocate_raw(100);
size_t used_before = fresh_arena.used_bytes();
CHECK(used_before == 100);
@@ -373,7 +374,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
CHECK(fresh_arena.used_bytes() == 0); // Memory should be reclaimed
// Test case where it's NOT the last allocation - memory cannot be reclaimed
ArenaAllocator arena2(256);
Arena arena2(256);
void *ptr1 = arena2.allocate_raw(50);
(void)arena2.allocate_raw(50);
size_t used_before2 = arena2.used_bytes();
@@ -396,7 +397,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
SUBCASE("in-place extension - growing") {
ArenaAllocator fresh_arena(1024);
Arena fresh_arena(1024);
void *ptr = fresh_arena.allocate_raw(100);
CHECK(ptr != nullptr);
@@ -417,7 +418,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
SUBCASE("in-place shrinking") {
ArenaAllocator fresh_arena(1024);
Arena fresh_arena(1024);
void *ptr = fresh_arena.allocate_raw(200);
std::memset(ptr, 0xCD, 200);
@@ -434,7 +435,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
SUBCASE("copy when can't extend in place") {
ArenaAllocator fresh_arena(256); // Larger block to avoid edge cases
Arena fresh_arena(256); // Larger block to avoid edge cases
// Allocate first chunk
void *ptr1 = fresh_arena.allocate_raw(60);
@@ -469,7 +470,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
SUBCASE("copy when insufficient space for extension") {
ArenaAllocator fresh_arena(100);
Arena fresh_arena(100);
// Allocate almost all space
void *ptr = fresh_arena.allocate_raw(90);
@@ -488,7 +489,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
SUBCASE("realloc with custom alignment") {
ArenaAllocator fresh_arena(1024);
Arena fresh_arena(1024);
// Allocate with specific alignment
void *ptr = fresh_arena.allocate_raw(50, 16);
@@ -508,7 +509,7 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
SUBCASE("realloc stress test") {
ArenaAllocator fresh_arena(512);
Arena fresh_arena(512);
void *ptr = fresh_arena.allocate_raw(50);
size_t current_size = 50;
@@ -532,3 +533,225 @@ TEST_CASE("ArenaAllocator realloc functionality") {
}
}
}
TEST_CASE("format function fallback codepath") {
SUBCASE("single-pass optimization success") {
Arena arena(128);
auto result = format(arena, "Hello %s! Number: %d", "World", 42);
CHECK(result == "Hello World! Number: 42");
CHECK(result.length() == 23);
}
SUBCASE("fallback when speculative formatting fails") {
// Create arena with limited space to force fallback
Arena arena(16);
// Consume most space to leave insufficient room for speculative formatting
arena.allocate<char>(10);
CHECK(arena.available_in_current_block() == 6);
// Format string larger than available space - should trigger fallback
std::string long_string = "This is a very long string that won't fit";
auto result = format(arena, "Prefix: %s with %d", long_string.c_str(), 123);
std::string expected =
"Prefix: This is a very long string that won't fit with 123";
CHECK(result == expected);
CHECK(result.length() == expected.length());
}
SUBCASE("edge case - exactly available space") {
Arena arena(32);
arena.allocate<char>(20); // Leave 12 bytes
CHECK(arena.available_in_current_block() == 12);
// Format that needs exactly available space (should still use fallback due
// to null terminator)
auto result = format(arena, "Test%d", 123); // "Test123" = 7 chars
CHECK(result == "Test123");
CHECK(result.length() == 7);
}
SUBCASE("allocate_remaining_space postcondition") {
// Test empty arena
Arena empty_arena(64);
auto space1 = empty_arena.allocate_remaining_space();
CHECK(space1.allocated_bytes >= 1);
CHECK(space1.allocated_bytes == 64);
// Test full arena (should create new block)
Arena full_arena(32);
full_arena.allocate<char>(32); // Fill completely
auto space2 = full_arena.allocate_remaining_space();
CHECK(space2.allocated_bytes >= 1);
CHECK(space2.allocated_bytes == 32); // New block created
}
SUBCASE("format error handling") {
Arena arena(64);
// Test with invalid format (should return empty string_view)
// Note: This is hard to trigger reliably across platforms,
// so we focus on successful cases in the other subcases
auto result = format(arena, "Valid format: %d", 42);
CHECK(result == "Valid format: 42");
}
}
// Test object with non-trivial destructor for Arena::Ptr testing
class TestObject {
public:
static int destructor_count;
static int constructor_count;
int value;
TestObject(int v) : value(v) { constructor_count++; }
~TestObject() { destructor_count++; }
static void reset_counters() {
constructor_count = 0;
destructor_count = 0;
}
};
int TestObject::destructor_count = 0;
int TestObject::constructor_count = 0;
// Test struct with trivial destructor
struct TrivialObject {
int value;
TrivialObject(int v) : value(v) {}
};
TEST_CASE("Arena::Ptr smart pointer functionality") {
TestObject::reset_counters();
SUBCASE("construct returns raw pointer for trivially destructible types") {
Arena arena;
auto ptr = arena.construct<TrivialObject>(42);
static_assert(std::is_same_v<decltype(ptr), TrivialObject *>,
"construct() should return raw pointer for trivially "
"destructible types");
CHECK(ptr != nullptr);
CHECK(ptr->value == 42);
}
SUBCASE("construct returns Arena::Ptr for non-trivially "
"destructible types") {
Arena arena;
auto ptr = arena.construct<TestObject>(42);
static_assert(std::is_same_v<decltype(ptr), Arena::Ptr<TestObject>>,
"construct() should return Arena::Ptr for non-trivially "
"destructible types");
CHECK(ptr);
CHECK(ptr->value == 42);
CHECK(TestObject::constructor_count == 1);
CHECK(TestObject::destructor_count == 0);
}
SUBCASE("Arena::Ptr calls destructor on destruction") {
Arena arena;
{
auto ptr = arena.construct<TestObject>(42);
CHECK(TestObject::constructor_count == 1);
CHECK(TestObject::destructor_count == 0);
} // ptr goes out of scope
CHECK(TestObject::destructor_count == 1);
}
SUBCASE("Arena::Ptr move semantics") {
Arena arena;
auto ptr1 = arena.construct<TestObject>(42);
CHECK(TestObject::constructor_count == 1);
auto ptr2 = std::move(ptr1);
CHECK(!ptr1); // ptr1 should be null after move
CHECK(ptr2);
CHECK(ptr2->value == 42);
CHECK(TestObject::destructor_count == 0); // No destruction yet
ptr2.reset();
CHECK(TestObject::destructor_count == 1); // Destructor called
}
SUBCASE("Arena::Ptr access operators") {
Arena arena;
auto ptr = arena.construct<TestObject>(123);
// Test operator->
CHECK(ptr->value == 123);
// Test operator*
CHECK((*ptr).value == 123);
// Test get()
TestObject *raw_ptr = ptr.get();
CHECK(raw_ptr != nullptr);
CHECK(raw_ptr->value == 123);
// Test bool conversion
CHECK(ptr);
CHECK(static_cast<bool>(ptr) == true);
}
SUBCASE("Arena::Ptr reset functionality") {
Arena arena;
auto ptr = arena.construct<TestObject>(42);
CHECK(TestObject::constructor_count == 1);
CHECK(TestObject::destructor_count == 0);
ptr.reset();
CHECK(!ptr);
CHECK(TestObject::destructor_count == 1);
// Reset with new object
TestObject *raw_obj = arena.construct<TestObject>(84).release();
ptr.reset(raw_obj);
CHECK(ptr);
CHECK(ptr->value == 84);
CHECK(TestObject::constructor_count == 2);
CHECK(TestObject::destructor_count == 1);
}
SUBCASE("Arena::Ptr release functionality") {
Arena arena;
auto ptr = arena.construct<TestObject>(42);
TestObject *raw_ptr = ptr.release();
CHECK(!ptr); // ptr should be null after release
CHECK(raw_ptr != nullptr);
CHECK(raw_ptr->value == 42);
CHECK(TestObject::destructor_count == 0); // No destructor called
// Manually call destructor (since we released ownership)
raw_ptr->~TestObject();
CHECK(TestObject::destructor_count == 1);
}
SUBCASE("Arena::Ptr move assignment") {
Arena arena;
auto ptr1 = arena.construct<TestObject>(42);
auto ptr2 = arena.construct<TestObject>(84);
CHECK(TestObject::constructor_count == 2);
CHECK(TestObject::destructor_count == 0);
ptr1 = std::move(ptr2); // Should destroy first object, move second
CHECK(!ptr2); // ptr2 should be null
CHECK(ptr1);
CHECK(ptr1->value == 84);
CHECK(TestObject::destructor_count == 1); // First object destroyed
}
}
-1
View File
@@ -1,4 +1,3 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "../benchmarks/test_data.hpp"
#include "parser_comparison.hpp"
#include <doctest/doctest.h>
+241 -95
View File
@@ -1,122 +1,268 @@
#include "arena_allocator.hpp"
#include "config.hpp"
#include "connection.hpp"
#include "http_handler.hpp"
#include "perfetto_categories.hpp"
#include <atomic>
#include "server.hpp"
#include <chrono>
#include <doctest/doctest.h>
#include <fcntl.h>
#include <poll.h>
#include <string>
#include <thread>
#include <unistd.h>
// Perfetto static storage for tests
PERFETTO_TRACK_EVENT_STATIC_STORAGE();
// Test to demonstrate HTTP pipelining response ordering issue
//
// HTTP/1.1 pipelining allows multiple requests to be sent on a single
// connection without waiting for responses, but responses MUST be sent in the
// same order as requests were received (RFC 2616 Section 8.1.2.2).
//
// This test sends two pipelined requests:
// 1. GET /ok - Slow response (goes through 4-stage pipeline processing)
// 2. GET /metrics - Fast response (handled directly, just collects metrics)
//
// Even though /ok takes longer to process due to pipeline overhead, the /ok
// response should be sent first since it was requested first. Currently this
// test FAILS because the faster /metrics response completes before /ok and
// gets sent out of order.
TEST_CASE("HTTP pipelined responses out of order") {
weaseldb::Config config;
HttpHandler handler(config);
auto server = Server::create(config, handler, {});
int fd = server->create_local_connection();
// Global variable needed by Connection
std::atomic<int> activeConnections{0};
auto runThread = std::thread{[&]() { server->run(); }};
// Simple test helper since Connection has complex constructor requirements
struct TestConnectionData {
ArenaAllocator arena;
std::string message_buffer;
void *user_data = nullptr;
// Send two pipelined requests in a single write() call
// Request order: /ok first, then /metrics
// Expected response order: /ok response first, then /metrics response
// Actual result: /metrics response first (fast), then /ok response (slow)
std::string pipelined_requests = "GET /ok HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: keep-alive\r\n"
"\r\n"
"GET /metrics HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: keep-alive\r\n"
"\r\n";
void append_message(std::string_view data) { message_buffer += data; }
int w = write(fd, pipelined_requests.c_str(), pipelined_requests.size());
REQUIRE(w == static_cast<int>(pipelined_requests.size()));
ArenaAllocator &get_arena() { return arena; }
const std::string &getResponse() const { return message_buffer; }
void clearResponse() { message_buffer.clear(); }
void reset() {
arena.reset();
message_buffer.clear();
// Set socket to non-blocking
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// Read all responses with non-blocking I/O and poll
char buf[8192];
int total_read = 0;
bool found_ok = false;
bool found_http_response = false;
std::string ok_response_header = "Content-Length: 2";
while (true) {
// Use poll to wait for data availability
struct pollfd pfd = {fd, POLLIN, 0};
int poll_result = poll(&pfd, 1, -1); // Block indefinitely
if (poll_result > 0 && (pfd.revents & POLLIN)) {
int r = read(fd, buf + total_read, sizeof(buf) - total_read - 1);
if (r > 0) {
printf("%.*s", r, buf + total_read);
total_read += r;
// Check if we have what we need after each read
buf[total_read] = '\0';
std::string current_data(buf, total_read);
found_http_response =
current_data.find("HTTP/1.1") != std::string::npos;
found_ok = current_data.find(ok_response_header) != std::string::npos;
// If we have both HTTP response and ok_response_header, we can proceed
// with the test
if (found_http_response && found_ok) {
break;
}
} else if (r == 0) {
REQUIRE(false);
break; // EOF
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
REQUIRE(false);
}
}
};
TEST_CASE("HttpHandler route parsing") {
SUBCASE("GET routes") {
CHECK(HttpHandler::parseRoute("GET", "/v1/version") ==
HttpRoute::GET_version);
CHECK(HttpHandler::parseRoute("GET", "/v1/subscribe") ==
HttpRoute::GET_subscribe);
CHECK(HttpHandler::parseRoute("GET", "/v1/status") ==
HttpRoute::GET_status);
CHECK(HttpHandler::parseRoute("GET", "/v1/retention") ==
HttpRoute::GET_retention);
CHECK(HttpHandler::parseRoute("GET", "/metrics") == HttpRoute::GET_metrics);
CHECK(HttpHandler::parseRoute("GET", "/ok") == HttpRoute::GET_ok);
}
SUBCASE("POST routes") {
CHECK(HttpHandler::parseRoute("POST", "/v1/commit") ==
HttpRoute::POST_commit);
buf[total_read] = '\0';
std::string response_data(buf, total_read);
// Ensure we found both HTTP response and ok_response_header
REQUIRE(found_http_response);
REQUIRE(found_ok);
// Find first occurrence of ok_response_header in response body
std::size_t ok_pos = response_data.find(ok_response_header);
REQUIRE(ok_pos != std::string::npos);
// Count HTTP response status lines before the /ok response body
// This tests response ordering: should be exactly 1 (the /ok response itself)
std::string before_ok = response_data.substr(0, ok_pos);
int http_response_count = 0;
std::size_t pos = 0;
while ((pos = before_ok.find("HTTP/1.1", pos)) != std::string::npos) {
http_response_count++;
pos += 8;
}
SUBCASE("PUT routes") {
CHECK(HttpHandler::parseRoute("PUT", "/v1/retention/policy1") ==
HttpRoute::PUT_retention);
}
// Assert there's exactly one HTTP response line before /ok response body
// If http_response_count == 2, it means /metrics response came first (wrong
// order) If http_response_count == 1, it means /ok response came first
// (correct order)
CHECK(http_response_count == 1);
SUBCASE("DELETE routes") {
CHECK(HttpHandler::parseRoute("DELETE", "/v1/retention/policy1") ==
HttpRoute::DELETE_retention);
}
SUBCASE("Unknown routes") {
CHECK(HttpHandler::parseRoute("GET", "/unknown") == HttpRoute::NotFound);
CHECK(HttpHandler::parseRoute("PATCH", "/v1/version") ==
HttpRoute::NotFound);
}
SUBCASE("Query parameters stripped") {
CHECK(HttpHandler::parseRoute("GET", "/v1/version?foo=bar") ==
HttpRoute::GET_version);
}
close(fd);
server->shutdown();
runThread.join();
}
TEST_CASE("HttpHandler route parsing edge cases") {
// Test just the static route parsing method since full integration testing
// would require complex Connection setup with server dependencies
TEST_CASE("HTTP pipelined POST requests race condition") {
weaseldb::Config config;
HttpHandler handler(config);
auto server = Server::create(config, handler, {});
int fd = server->create_local_connection();
SUBCASE("Route parsing with query parameters") {
CHECK(HttpHandler::parseRoute("GET", "/v1/version?param=value") ==
HttpRoute::GET_version);
CHECK(HttpHandler::parseRoute("GET", "/v1/subscribe?stream=true") ==
HttpRoute::GET_subscribe);
auto runThread = std::thread{[&]() { server->run(); }};
// Create a POST request with JSON body that requires parsing
std::string json_body = R"({
"request_id": "test-123",
"leader_id": "leader-1",
"read_version": 1,
"preconditions": [],
"operations": [{"write": {"key": "dGVzdA==", "value": "dmFsdWU="}}]
})";
std::string first_post = "POST /v1/commit HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Type: application/json\r\n"
"Content-Length: " +
std::to_string(json_body.size()) +
"\r\n"
"Connection: keep-alive\r\n"
"\r\n" +
json_body;
std::string second_get = "GET /v1/version HTTP/1.1\r\n"
"Host: localhost\r\n"
"Connection: close\r\n"
"\r\n";
// Send POST request followed immediately by GET request
// This creates a scenario where the GET request starts parsing
// while the POST response is being written (triggering the reset)
int w1 = write(fd, first_post.c_str(), first_post.size());
REQUIRE(w1 == static_cast<int>(first_post.size()));
int w2 = write(fd, second_get.c_str(), second_get.size());
REQUIRE(w2 == static_cast<int>(second_get.size()));
// Read responses using blocking I/O (deterministic synchronization)
char buf[4096];
int total_read = 0;
int responses_found = 0;
while (total_read < 4000) {
int r = read(fd, buf + total_read, sizeof(buf) - total_read - 1);
if (r <= 0)
break;
total_read += r;
buf[total_read] = '\0';
std::string response(buf, total_read);
std::size_t pos = 0;
while ((pos = response.find("HTTP/1.1", pos)) != std::string::npos) {
responses_found++;
pos += 8;
}
SUBCASE("Retention policy routes") {
CHECK(HttpHandler::parseRoute("PUT", "/v1/retention/policy123") ==
HttpRoute::PUT_retention);
CHECK(HttpHandler::parseRoute("DELETE", "/v1/retention/policy456") ==
HttpRoute::DELETE_retention);
CHECK(HttpHandler::parseRoute("GET", "/v1/retention/policy789") ==
HttpRoute::GET_retention);
if (responses_found >= 2)
break;
}
// Should get responses to both requests
// Race condition might cause parsing errors or connection issues
CHECK(responses_found >= 1); // At minimum should handle first request
close(fd);
server->shutdown();
runThread.join();
}
// Test helper to verify the new hook functionality
struct MockConnectionHandler : public ConnectionHandler {
bool write_progress_called = false;
bool write_buffer_drained_called = false;
TEST_CASE("HTTP URL split across multiple writes") {
weaseldb::Config config;
HttpHandler handler(config);
auto server = Server::create(config, handler, {});
int fd = server->create_local_connection();
void on_write_progress(std::unique_ptr<Connection> &) override {
write_progress_called = true;
auto runThread = std::thread{[&]() { server->run(); }};
// Test URL accumulation by splitting the URL across multiple writes
// This would have caught the original bug where URL string_view pointed
// to llhttp's internal buffer that gets reused between writes
// Split "GET /metrics HTTP/1.1\r\n" across multiple writes
std::string part1 = "GET /met";
std::string part2 = "rics HTTP/1.1\r\n";
std::string headers = "Host: localhost\r\n"
"Connection: close\r\n"
"\r\n";
// Write URL in two parts - this tests URL accumulation
int w1 = write(fd, part1.c_str(), part1.size());
REQUIRE(w1 == static_cast<int>(part1.size()));
// Attempt to trigger separate llhttp parsing calls
std::this_thread::sleep_for(std::chrono::milliseconds(1));
int w2 = write(fd, part2.c_str(), part2.size());
REQUIRE(w2 == static_cast<int>(part2.size()));
int w3 = write(fd, headers.c_str(), headers.size());
REQUIRE(w3 == static_cast<int>(headers.size()));
// Read response
char buf[4096];
int total_read = 0;
bool found_metrics_response = false;
while (total_read < 4000) {
int r = read(fd, buf + total_read, sizeof(buf) - total_read - 1);
if (r <= 0)
break;
total_read += r;
buf[total_read] = '\0';
std::string response(buf, total_read);
// Check for successful metrics response (not 404)
if (response.find("HTTP/1.1 200 OK") != std::string::npos &&
response.find("text/plain; version=0.0.4") != std::string::npos) {
found_metrics_response = true;
break;
}
void on_write_buffer_drained(std::unique_ptr<Connection> &) override {
write_buffer_drained_called = true;
// Check for 404 which would indicate URL accumulation failed
if (response.find("HTTP/1.1 404") != std::string::npos) {
FAIL("Got 404 - URL accumulation failed, split URL was not properly "
"reconstructed");
}
};
TEST_CASE("ConnectionHandler hooks") {
SUBCASE("on_write_buffer_drained hook exists") {
MockConnectionHandler handler;
// Verify hooks are available and can be overridden
CHECK_FALSE(handler.write_progress_called);
CHECK_FALSE(handler.write_buffer_drained_called);
// Would normally be called by Server during write operations
std::unique_ptr<Connection> null_conn;
handler.on_write_progress(null_conn);
handler.on_write_buffer_drained(null_conn);
CHECK(handler.write_progress_called);
CHECK(handler.write_buffer_drained_called);
}
REQUIRE(found_metrics_response);
close(fd);
server->shutdown();
runThread.join();
}
+830
View File
@@ -0,0 +1,830 @@
#include <doctest/doctest.h>
#include "arena.hpp"
#include "metric.hpp"
#include <atomic>
#include <cmath>
#include <latch>
#include <sstream>
#include <thread>
#include <vector>
TEST_CASE("metric validation functions") {
SUBCASE("valid metric names") {
CHECK(metric::is_valid_metric_name("valid_name"));
CHECK(metric::is_valid_metric_name("ValidName"));
CHECK(metric::is_valid_metric_name("valid:name"));
CHECK(metric::is_valid_metric_name("_valid"));
CHECK(metric::is_valid_metric_name("valid_123"));
CHECK(metric::is_valid_metric_name("prometheus_metric_name"));
}
SUBCASE("invalid metric names") {
CHECK_FALSE(metric::is_valid_metric_name(""));
CHECK_FALSE(metric::is_valid_metric_name("123invalid"));
CHECK_FALSE(metric::is_valid_metric_name("invalid-name"));
CHECK_FALSE(metric::is_valid_metric_name("invalid.name"));
CHECK_FALSE(metric::is_valid_metric_name("invalid name"));
}
SUBCASE("valid label keys") {
CHECK(metric::is_valid_label_key("valid_key"));
CHECK(metric::is_valid_label_key("ValidKey"));
CHECK(metric::is_valid_label_key("valid123"));
CHECK(metric::is_valid_label_key("_valid"));
}
SUBCASE("invalid label keys") {
CHECK_FALSE(metric::is_valid_label_key(""));
CHECK_FALSE(metric::is_valid_label_key("123invalid"));
CHECK_FALSE(metric::is_valid_label_key("invalid:key"));
CHECK_FALSE(metric::is_valid_label_key("invalid-key"));
CHECK_FALSE(metric::is_valid_label_key("__reserved"));
CHECK_FALSE(metric::is_valid_label_key("__internal"));
}
SUBCASE("valid label values") {
CHECK(metric::is_valid_label_value("any_value"));
CHECK(metric::is_valid_label_value("123"));
CHECK(metric::is_valid_label_value("special-chars.allowed"));
CHECK(metric::is_valid_label_value(""));
CHECK(metric::is_valid_label_value("unicode测试"));
}
}
TEST_CASE("counter basic functionality") {
auto counter_family =
metric::create_counter("test_counter", "Test counter help");
SUBCASE("create counter with no labels") {
auto counter = counter_family.create({});
counter.inc(1.0);
counter.inc(2.5);
counter.inc(); // Default increment of 1.0
}
SUBCASE("create counter with labels") {
auto counter =
counter_family.create({{"method", "GET"}, {"status", "200"}});
counter.inc(5.0);
// Same labels should return same instance (idempotent)
auto counter2 =
counter_family.create({{"method", "GET"}, {"status", "200"}});
counter2.inc(3.0);
}
SUBCASE("label sorting") {
// Labels should be sorted by key
auto counter1 =
counter_family.create({{"z_key", "value"}, {"a_key", "value"}});
auto counter2 =
counter_family.create({{"a_key", "value"}, {"z_key", "value"}});
// These should be the same instance due to label sorting
counter1.inc(1.0);
counter2.inc(2.0); // Should add to same counter
}
}
TEST_CASE("gauge basic functionality") {
auto gauge_family = metric::create_gauge("test_gauge", "Test gauge help");
SUBCASE("gauge operations") {
auto gauge = gauge_family.create({{"instance", "test"}});
gauge.set(10.0);
gauge.inc(5.0);
gauge.dec(3.0);
gauge.inc(); // Default increment
gauge.dec(); // Default decrement
}
SUBCASE("gauge with multiple instances") {
auto gauge1 = gauge_family.create({{"instance", "test1"}});
auto gauge2 = gauge_family.create({{"instance", "test2"}});
gauge1.set(100.0);
gauge2.set(200.0);
gauge1.inc(50.0);
gauge2.dec(25.0);
}
}
TEST_CASE("histogram basic functionality") {
auto hist_family =
metric::create_histogram("test_latency", "Test latency histogram",
metric::exponential_buckets(0.1, 2.0, 5));
SUBCASE("histogram observations") {
auto hist = hist_family.create({{"endpoint", "/api"}});
hist.observe(0.05); // Below first bucket
hist.observe(0.3); // Between buckets
hist.observe(1.5); // Between buckets
hist.observe(10.0); // Above all explicit buckets (goes in +Inf)
}
SUBCASE("histogram bucket validation") {
// Buckets should be sorted and deduplicated, with +Inf added
auto hist_family2 = metric::create_histogram(
"test_hist2", "Test",
std::initializer_list<double>{5.0, 1.0, 2.5, 1.0,
0.5}); // Unsorted with duplicate
auto hist = hist_family2.create({});
hist.observe(0.1);
hist.observe(1.5);
hist.observe(100.0); // Should go in +Inf bucket
}
}
TEST_CASE("histogram bucket generators") {
SUBCASE("linear_buckets basic functionality") {
// Linear buckets: start=0, width=10, count=5 -> {0, 10, 20, 30, 40}
auto buckets = metric::linear_buckets(0.0, 10.0, 5);
CHECK(buckets.size() == 5); // exactly count buckets
CHECK(buckets[0] == 0.0);
CHECK(buckets[1] == 10.0);
CHECK(buckets[2] == 20.0);
CHECK(buckets[3] == 30.0);
CHECK(buckets[4] == 40.0);
}
SUBCASE("linear_buckets with non-zero start") {
// Linear buckets: start=5, width=2.5, count=3 -> {5, 7.5, 10}
auto buckets = metric::linear_buckets(5.0, 2.5, 3);
CHECK(buckets.size() == 3);
CHECK(buckets[0] == 5.0);
CHECK(buckets[1] == 7.5);
CHECK(buckets[2] == 10.0);
}
SUBCASE("linear_buckets edge cases") {
// Zero count should give empty vector
auto zero_buckets = metric::linear_buckets(100.0, 10.0, 0);
CHECK(zero_buckets.size() == 0);
// Negative start should work
auto negative_buckets = metric::linear_buckets(-10.0, 5.0, 2);
CHECK(negative_buckets.size() == 2);
CHECK(negative_buckets[0] == -10.0);
CHECK(negative_buckets[1] == -5.0);
}
SUBCASE("exponential_buckets basic functionality") {
// Exponential buckets: start=1, factor=2, count=5 -> {1, 2, 4, 8, 16}
auto buckets = metric::exponential_buckets(1.0, 2.0, 5);
CHECK(buckets.size() == 5); // exactly count buckets
CHECK(buckets[0] == 1.0);
CHECK(buckets[1] == 2.0);
CHECK(buckets[2] == 4.0);
CHECK(buckets[3] == 8.0);
CHECK(buckets[4] == 16.0);
}
SUBCASE("exponential_buckets different factor") {
// Exponential buckets: start=0.1, factor=10, count=3 -> {0.1, 1, 10}
auto buckets = metric::exponential_buckets(0.1, 10.0, 3);
CHECK(buckets.size() == 3);
CHECK(buckets[0] == doctest::Approx(0.1));
CHECK(buckets[1] == doctest::Approx(1.0));
CHECK(buckets[2] == doctest::Approx(10.0));
}
SUBCASE("exponential_buckets typical latency pattern") {
// Typical web service latency buckets: 5ms, 10ms, 20ms, 40ms, 80ms, etc.
auto buckets = metric::exponential_buckets(0.005, 2.0, 8);
CHECK(buckets.size() == 8);
CHECK(buckets[0] == doctest::Approx(0.005)); // 5ms
CHECK(buckets[1] == doctest::Approx(0.010)); // 10ms
CHECK(buckets[2] == doctest::Approx(0.020)); // 20ms
CHECK(buckets[3] == doctest::Approx(0.040)); // 40ms
CHECK(buckets[4] == doctest::Approx(0.080)); // 80ms
CHECK(buckets[5] == doctest::Approx(0.160)); // 160ms
CHECK(buckets[6] == doctest::Approx(0.320)); // 320ms
CHECK(buckets[7] == doctest::Approx(0.640)); // 640ms
}
SUBCASE("exponential_buckets edge cases") {
// Zero count should give empty vector
auto zero_buckets = metric::exponential_buckets(5.0, 3.0, 0);
CHECK(zero_buckets.size() == 0);
}
SUBCASE("bucket generators with histogram creation") {
// Test that generated buckets work correctly with histogram creation
auto linear_hist = metric::create_histogram(
"linear_test", "Linear test", metric::linear_buckets(0, 100, 5));
auto linear_instance = linear_hist.create({{"type", "linear"}});
// Test observations fall into expected buckets
linear_instance.observe(50); // Should fall into 100 bucket
linear_instance.observe(150); // Should fall into 200 bucket
linear_instance.observe(1000); // Should fall into +Inf bucket
auto exp_hist =
metric::create_histogram("exp_test", "Exponential test",
metric::exponential_buckets(0.001, 10.0, 4));
auto exp_instance = exp_hist.create({{"type", "exponential"}});
// Test typical latency measurements
exp_instance.observe(0.0005); // Should fall into 0.001 bucket (1ms)
exp_instance.observe(0.005); // Should fall into 0.01 bucket (10ms)
exp_instance.observe(0.05); // Should fall into 0.1 bucket (100ms)
exp_instance.observe(5.0); // Should fall into +Inf bucket
}
SUBCASE("prometheus compatibility verification") {
// Verify our bucket generation matches Prometheus Go client behavior
// Linear buckets equivalent to Prometheus LinearBuckets(0, 10, 5)
auto our_linear = metric::linear_buckets(0, 10, 5);
std::vector<double> expected_linear = {0, 10, 20, 30, 40};
CHECK(our_linear == expected_linear);
// Exponential buckets equivalent to Prometheus ExponentialBuckets(1, 2, 5)
auto our_exp = metric::exponential_buckets(1, 2, 5);
std::vector<double> expected_exp = {1, 2, 4, 8, 16};
CHECK(our_exp == expected_exp);
// Default Prometheus histogram buckets (exponential)
auto default_buckets = metric::exponential_buckets(0.005, 2.5, 9);
// Should be: .005, .0125, .03125, .078125, .1953125,
// .48828125, 1.220703125, 3.0517578125, 7.62939453125
CHECK(default_buckets.size() == 9);
CHECK(default_buckets[0] == doctest::Approx(0.005));
CHECK(default_buckets[1] == doctest::Approx(0.0125));
CHECK(default_buckets[8] == doctest::Approx(7.62939453125));
}
}
TEST_CASE("callback-based metrics") {
auto counter_family =
metric::create_counter("callback_counter", "Callback counter");
auto gauge_family = metric::create_gauge("callback_gauge", "Callback gauge");
SUBCASE("counter callback") {
counter_family.register_callback({{"type", "callback"}},
[]() { return 42.0; });
// Callback should be called during render
Arena arena;
auto output = metric::render(arena);
CHECK(output.size() > 0);
}
SUBCASE("gauge callback") {
gauge_family.register_callback({{"type", "callback"}},
[]() { return 123.5; });
Arena arena;
auto output = metric::render(arena);
CHECK(output.size() > 0);
}
SUBCASE("callback conflict detection") {
// First create a static instance
auto counter = counter_family.create({{"conflict", "test"}});
counter.inc(1.0);
// Then try to register a callback with same labels - should abort
// This is a validation test that would abort in debug builds
}
}
TEST_CASE("prometheus text format rendering") {
Arena arena;
// Create some metrics
auto counter_family =
metric::create_counter("http_requests_total", "Total HTTP requests");
auto counter = counter_family.create({{"method", "GET"}, {"status", "200"}});
counter.inc(1000);
auto gauge_family =
metric::create_gauge("memory_usage_bytes", "Memory usage");
auto gauge = gauge_family.create({{"type", "heap"}});
gauge.set(1048576);
auto hist_family = metric::create_histogram(
"request_duration_seconds", "Request duration",
metric::exponential_buckets(0.1, 2.0, 3)); // 0.1, 0.2, 0.4, 0.8
auto hist = hist_family.create({{"handler", "api"}});
hist.observe(0.25);
hist.observe(0.75);
hist.observe(1.5);
SUBCASE("render format validation") {
auto output = metric::render(arena);
CHECK(output.size() > 0);
// Basic format checks
bool found_help = false;
bool found_type = false;
bool found_metric_line = false;
for (const auto &line : output) {
if (line.starts_with("# HELP"))
found_help = true;
if (line.find("# TYPE") != line.npos)
found_type = true;
if (line.find("http_requests_total") != std::string_view::npos)
found_metric_line = true;
}
CHECK(found_help);
CHECK(found_type);
CHECK(found_metric_line);
}
SUBCASE("special value formatting") {
auto special_gauge_family =
metric::create_gauge("special_values", "Special value test");
auto special_gauge = special_gauge_family.create({});
special_gauge.set(std::numeric_limits<double>::infinity());
auto output = metric::render(arena);
// Should contain "+Inf" representation
bool found_inf = false;
for (const auto &line : output) {
if (line.find("+Inf") != std::string_view::npos) {
found_inf = true;
break;
}
}
CHECK(found_inf);
}
}
TEST_CASE("thread safety") {
constexpr int num_threads = 8;
constexpr int ops_per_thread = 1000;
SUBCASE("counter single-writer semantics") {
auto counter_family =
metric::create_counter("thread_test_counter", "Thread test");
std::vector<std::thread> threads;
std::latch start_latch{num_threads};
// Each thread creates its own counter instance (safe)
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&, i]() {
auto counter =
counter_family.create({{"thread_id", std::to_string(i)}});
start_latch.arrive_and_wait();
for (int j = 0; j < ops_per_thread; ++j) {
counter.inc(1.0);
}
});
}
for (auto &t : threads) {
t.join();
}
}
SUBCASE("gauge multi-writer contention") {
auto gauge_family =
metric::create_gauge("thread_test_gauge", "Thread test gauge");
std::vector<std::thread> threads;
std::latch start_latch{num_threads};
// Multiple threads create gauges with the same labels, writing to the same
// underlying state, testing CAS contention.
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&]() {
auto gauge = gauge_family.create({{"shared", "true"}});
start_latch.arrive_and_wait();
for (int j = 0; j < ops_per_thread; ++j) {
gauge.inc(1.0);
}
});
}
for (auto &t : threads) {
t.join();
}
}
SUBCASE("histogram single-writer per thread") {
auto hist_family =
metric::create_histogram("thread_test_hist", "Thread test histogram",
std::initializer_list<double>{0.1, 0.5, 1.0});
std::vector<std::thread> threads;
std::latch start_latch{num_threads};
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&, i]() {
auto hist = hist_family.create({{"thread_id", std::to_string(i)}});
start_latch.arrive_and_wait();
for (int j = 0; j < ops_per_thread; ++j) {
hist.observe(static_cast<double>(j) / ops_per_thread);
}
});
}
for (auto &t : threads) {
t.join();
}
}
SUBCASE("concurrent render calls") {
// Multiple threads calling render concurrently should be safe (serialized
// by mutex)
auto counter_family = metric::create_counter("render_test", "Render test");
auto counter = counter_family.create({});
counter.inc(100);
std::vector<std::thread> threads;
std::latch start_latch{num_threads};
std::atomic<int> success_count{0};
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&]() {
start_latch.arrive_and_wait();
Arena arena;
auto output = metric::render(arena);
if (output.size() > 0) {
success_count.fetch_add(1);
}
});
}
for (auto &t : threads) {
t.join();
}
CHECK(success_count.load() == num_threads);
}
}
TEST_CASE("thread counter cleanup bug") {
return;
SUBCASE(
"counter and histogram values should persist after thread destruction") {
auto counter_family = metric::create_counter(
"thread_cleanup_counter", "Counter for thread cleanup test");
auto histogram_family = metric::create_histogram(
"thread_cleanup_histogram", "Histogram for thread cleanup test",
metric::linear_buckets(0.0, 1.0, 5)); // buckets: 0, 1, 2, 3, 4
// Variables to collect actual values from worker thread
double counter_value_in_thread = 0;
double histogram_sum_in_thread = 0;
// Create thread that increments metrics and then exits
std::thread worker([&]() {
auto counter = counter_family.create({{"worker", "cleanup_test"}});
auto histogram = histogram_family.create({{"worker", "cleanup_test"}});
counter.inc(1.0);
histogram.observe(1.5); // Should contribute to sum
// Measure actual values from within the thread (before ThreadInit
// destructor runs)
Arena thread_arena;
auto thread_output = metric::render(thread_arena);
for (const auto &line : thread_output) {
if (line.find("thread_cleanup_counter{worker=\"cleanup_test\"}") !=
std::string_view::npos) {
auto space_pos = line.rfind(' ');
if (space_pos != std::string_view::npos) {
auto value_str = line.substr(space_pos + 1);
if (value_str.back() == '\n') {
value_str.remove_suffix(1);
}
counter_value_in_thread = std::stod(std::string(value_str));
}
}
if (line.find(
"thread_cleanup_histogram_sum{worker=\"cleanup_test\"}") !=
std::string_view::npos) {
auto space_pos = line.rfind(' ');
if (space_pos != std::string_view::npos) {
auto value_str = line.substr(space_pos + 1);
if (value_str.back() == '\n') {
value_str.remove_suffix(1);
}
histogram_sum_in_thread = std::stod(std::string(value_str));
}
}
}
});
// Wait for thread to complete and destroy (triggering ThreadInit
// destructor)
worker.join();
// Measure values after thread cleanup
Arena arena;
auto output = metric::render(arena);
double counter_value_after = 0;
double histogram_sum_after = 0;
for (const auto &line : output) {
if (line.find("thread_cleanup_counter{worker=\"cleanup_test\"}") !=
std::string_view::npos) {
auto space_pos = line.rfind(' ');
if (space_pos != std::string_view::npos) {
auto value_str = line.substr(space_pos + 1);
if (value_str.back() == '\n') {
value_str.remove_suffix(1);
}
counter_value_after = std::stod(std::string(value_str));
}
}
if (line.find("thread_cleanup_histogram_sum{worker=\"cleanup_test\"}") !=
std::string_view::npos) {
auto space_pos = line.rfind(' ');
if (space_pos != std::string_view::npos) {
auto value_str = line.substr(space_pos + 1);
if (value_str.back() == '\n') {
value_str.remove_suffix(1);
}
histogram_sum_after = std::stod(std::string(value_str));
}
}
}
// Values should have been captured correctly within the thread
CHECK(counter_value_in_thread == 1.0);
CHECK(histogram_sum_in_thread == 1.5);
// The bug: These values should persist after thread cleanup but will be
// lost because ThreadInit destructor erases per-thread state without
// accumulating values
CHECK(counter_value_after == 1.0);
CHECK(histogram_sum_after == 1.5);
// The bug: After thread destruction, the counter and histogram values are
// lost because ThreadInit::~ThreadInit() calls
// family->perThreadState.erase(thread_id) without accumulating the values
// into global storage first. This causes counter values to "go backwards"
// when threads are destroyed, violating the monotonic property of counters.
}
}
TEST_CASE("memory management") {
SUBCASE("arena allocation in render") {
Arena arena;
auto initial_used = arena.used_bytes();
auto counter_family = metric::create_counter("memory_test", "Memory test");
auto counter = counter_family.create(
{{"large_label", "very_long_value_that_takes_space"}});
counter.inc(42);
auto output = metric::render(arena);
auto final_used = arena.used_bytes();
CHECK(output.size() > 0);
CHECK(final_used > initial_used); // Arena was used for string allocation
// All string_views should point to arena memory
for (const auto &line : output) {
CHECK(line.size() > 0);
}
}
SUBCASE("arena reset behavior") {
Arena arena;
auto counter_family = metric::create_counter("reset_test", "Reset test");
auto counter = counter_family.create({});
counter.inc(1);
// Render multiple times with arena resets
for (int i = 0; i < 5; ++i) {
auto output = metric::render(arena);
CHECK(output.size() > 0);
arena.reset(); // Should not affect metric values, only arena memory
}
// Final render should still work
auto final_output = metric::render(arena);
CHECK(final_output.size() > 0);
}
}
TEST_CASE("histogram pending buffer thread cleanup bug") {
for (int iterations = 0; iterations < 1000; ++iterations) {
// This test demonstrates the bug where pending histogram observations
// are lost when a thread dies because ThreadInit destructor doesn't
// flush pending data into shared before accumulating into global state.
metric::reset_metrics_for_testing();
auto hist_family = metric::create_histogram(
"pending_bug_test", "Test histogram for pending buffer bug",
{1.0}); // Single bucket for simplicity
std::atomic<bool> keep_rendering{true};
constexpr int num_threads = 100;
std::latch ready{2};
// Background thread that calls render in a tight loop to hold global mutex
std::thread render_thread([&]() {
ready.arrive_and_wait();
Arena arena;
while (keep_rendering.load(std::memory_order_relaxed)) {
metric::render(arena);
arena.reset();
}
});
// Don't spawn threads until render thread is running
ready.arrive_and_wait();
// Spawn threads that observe once and exit
std::vector<std::thread> observer_threads;
for (int i = 0; i < num_threads; ++i) {
observer_threads.emplace_back([&hist_family]() {
auto hist = hist_family.create({{"test", "observer"}});
hist.observe(0.5); // Goes into first bucket (le="1.0")
// Thread dies here - pending observations should be lost due to bug
});
}
// Join all observer threads
for (auto &t : observer_threads) {
t.join();
}
// Stop render thread
keep_rendering.store(false, std::memory_order_relaxed);
render_thread.join();
// Check if the worker's observations were preserved
Arena arena;
auto output = metric::render(arena);
// First, let's debug what we actually got
std::ostringstream debug_output;
for (const auto &line : output) {
debug_output << line;
}
std::string full_output = debug_output.str();
// Parse the output to find the worker's bucket count for le="2.0"
uint64_t worker_bucket_2_count = 0;
bool found_worker_metric = false;
// The render output alternates between metric name and value in separate
// string_views
for (size_t i = 0; i < output.size(); ++i) {
const auto &line = output[i];
// Look for: pending_bug_test_bucket{test="observer",le="1.0"}
if (line.find("pending_bug_test_bucket{test=\"observer\",le=\"1.0\"}") !=
std::string_view::npos) {
found_worker_metric = true;
// The value should be in the next element
if (i + 1 < output.size()) {
auto value_str = output[i + 1];
// Remove trailing newline if present
while (!value_str.empty() &&
(value_str.back() == '\n' || value_str.back() == '\r')) {
value_str.remove_suffix(1);
}
try {
worker_bucket_2_count = std::stoull(std::string(value_str));
} catch (const std::exception &e) {
MESSAGE("Failed to parse value: '"
<< value_str << "' from metric line: '" << line << "'");
MESSAGE("Full output:\n" << full_output);
throw;
}
}
break;
}
}
REQUIRE(found_worker_metric); // The metric should exist
// BUG: This will fail because pending observations are lost on thread death
// Expected: num_threads observations (each thread made 1 observation)
// Actual: less than num_threads (observations stuck in pending are lost
// when threads die)
CHECK_MESSAGE(
worker_bucket_2_count == num_threads,
"Expected "
<< num_threads << " observations but got " << worker_bucket_2_count
<< ". This indicates the pending buffer bug where observations "
<< "stuck in pending are lost when thread dies.");
}
}
TEST_CASE("render output deterministic order golden test") {
// Clean slate - reset all metrics before this test
metric::reset_metrics_for_testing();
Arena arena;
// Create a comprehensive set of metrics with deliberate ordering
// to test deterministic output
// Create counters with different family names and labels
auto z_counter_family =
metric::create_counter("z_last_counter", "Last counter alphabetically");
auto z_counter =
z_counter_family.create({{"method", "POST"}, {"handler", "api"}});
z_counter.inc(42.0);
auto a_counter_family =
metric::create_counter("a_first_counter", "First counter alphabetically");
auto a_counter1 = a_counter_family.create({{"status", "200"}});
auto a_counter2 = a_counter_family.create(
{{"method", "GET"}}); // Should come before status lexicographically
a_counter1.inc(100.0);
a_counter2.inc(200.0);
// Create gauges with different orderings
auto m_gauge_family = metric::create_gauge("m_middle_gauge", "Middle gauge");
auto m_gauge = m_gauge_family.create({{"type", "memory"}});
m_gauge.set(1024.0);
auto b_gauge_family = metric::create_gauge("b_second_gauge", "Second gauge");
auto b_gauge = b_gauge_family.create({{"region", "us-west"}});
b_gauge.set(256.0);
// Create histograms
auto x_hist_family = metric::create_histogram("x_histogram", "Test histogram",
{0.1, 0.5, 1.0});
auto x_hist = x_hist_family.create({{"endpoint", "/api/v1"}});
x_hist.observe(0.25);
x_hist.observe(0.75);
// Add some callbacks to test callback ordering
a_counter_family.register_callback({{"callback", "test"}},
[]() { return 123.0; });
m_gauge_family.register_callback({{"callback", "dynamic"}},
[]() { return 456.0; });
// Render the metrics
auto output = metric::render(arena);
// Concatenate all output into a single string
std::ostringstream oss;
for (const auto &line : output) {
oss << line;
}
std::string actual_output = oss.str();
// Define expected golden output - this represents the exact expected
// deterministic order
std::string expected_golden =
"# HELP a_first_counter First counter alphabetically\n"
"# TYPE a_first_counter counter\n"
"a_first_counter{callback=\"test\"} 123.0\n"
"a_first_counter{method=\"GET\"} 200.0\n"
"a_first_counter{status=\"200\"} 100.0\n"
"# HELP z_last_counter Last counter alphabetically\n"
"# TYPE z_last_counter counter\n"
"z_last_counter{handler=\"api\",method=\"POST\"} 42.0\n"
"# HELP b_second_gauge Second gauge\n"
"# TYPE b_second_gauge gauge\n"
"b_second_gauge{region=\"us-west\"} 256.0\n"
"# HELP m_middle_gauge Middle gauge\n"
"# TYPE m_middle_gauge gauge\n"
"m_middle_gauge{callback=\"dynamic\"} 456.0\n"
"m_middle_gauge{type=\"memory\"} 1024.0\n"
"# HELP x_histogram Test histogram\n"
"# TYPE x_histogram histogram\n"
"x_histogram_bucket{endpoint=\"/api/v1\",le=\"0.1\"} 0\n"
"x_histogram_bucket{endpoint=\"/api/v1\",le=\"0.5\"} 1\n"
"x_histogram_bucket{endpoint=\"/api/v1\",le=\"1.0\"} 2\n"
"x_histogram_bucket{endpoint=\"/api/v1\",le=\"+Inf\"} 2\n"
"x_histogram_sum{endpoint=\"/api/v1\"} 1.0\n"
"x_histogram_count{endpoint=\"/api/v1\"} 2\n";
// Check if output matches golden file
if (actual_output != expected_golden) {
MESSAGE("Render output does not match expected golden output.");
MESSAGE("This indicates the deterministic ordering has changed.");
MESSAGE("Expected output:\n" << expected_golden);
MESSAGE("Actual output:\n" << actual_output);
CHECK(false); // Force test failure
} else {
CHECK(true); // Test passes
}
}
+511
View File
@@ -0,0 +1,511 @@
#include <barrier>
#include <doctest/doctest.h>
#include <latch>
#include <thread>
#include <vector>
#include "reference.hpp"
namespace {
struct TestObject {
int value;
explicit TestObject(int v) : value(v) {}
};
struct Node {
int data;
Ref<Node> next;
WeakRef<Node> parent;
explicit Node(int d) : data(d) {}
};
// Classes for polymorphism testing
struct Base {
int base_value;
explicit Base(int v) : base_value(v) {}
virtual ~Base() = default;
virtual int get_value() const { return base_value; }
};
struct Derived : Base {
int derived_value;
explicit Derived(int base_v, int derived_v)
: Base(base_v), derived_value(derived_v) {}
int get_value() const override { return base_value + derived_value; }
};
struct AnotherDerived : Base {
int another_value;
explicit AnotherDerived(int base_v, int another_v)
: Base(base_v), another_value(another_v) {}
int get_value() const override { return base_value * another_value; }
};
// Classes to test polymorphic pointer address changes
struct Interface1 {
int interface1_data = 1;
virtual ~Interface1() = default;
virtual int get_interface1() const { return interface1_data; }
};
struct Interface2 {
int interface2_data = 2;
virtual ~Interface2() = default;
virtual int get_interface2() const { return interface2_data; }
};
// Multiple inheritance - this will cause pointer address changes
struct MultipleInheritance : Interface1, Interface2 {
int own_data;
explicit MultipleInheritance(int data) : own_data(data) {}
int get_own_data() const { return own_data; }
};
} // anonymous namespace
TEST_CASE("Ref basic functionality") {
SUBCASE("make_ref creates valid Ref") {
auto ref = make_ref<TestObject>(42);
CHECK(ref);
CHECK(ref.get() != nullptr);
CHECK(ref->value == 42);
CHECK((*ref).value == 42);
}
SUBCASE("explicit copy increments reference count") {
auto ref1 = make_ref<TestObject>(123);
auto ref2 = ref1.copy();
CHECK(ref1);
CHECK(ref2);
CHECK(ref1.get() == ref2.get());
CHECK(ref1->value == 123);
CHECK(ref2->value == 123);
}
SUBCASE("explicit copy assignment works correctly") {
auto ref1 = make_ref<TestObject>(100);
auto ref2 = make_ref<TestObject>(200);
ref2 = ref1.copy();
CHECK(ref1.get() == ref2.get());
CHECK(ref1->value == 100);
CHECK(ref2->value == 100);
}
SUBCASE("move construction transfers ownership") {
auto ref1 = make_ref<TestObject>(456);
auto *ptr = ref1.get();
auto ref2 = std::move(ref1);
CHECK(!ref1);
CHECK(ref2);
CHECK(ref2.get() == ptr);
CHECK(ref2->value == 456);
}
SUBCASE("move assignment transfers ownership") {
auto ref1 = make_ref<TestObject>(789);
auto ref2 = make_ref<TestObject>(999);
auto *ptr = ref1.get();
ref2 = std::move(ref1);
CHECK(!ref1);
CHECK(ref2);
CHECK(ref2.get() == ptr);
CHECK(ref2->value == 789);
}
SUBCASE("reset clears reference") {
auto ref = make_ref<TestObject>(111);
CHECK(ref);
ref.reset();
CHECK(!ref);
CHECK(ref.get() == nullptr);
}
}
TEST_CASE("WeakRef basic functionality") {
SUBCASE("construction from Ref") {
auto ref = make_ref<TestObject>(333);
WeakRef<TestObject> weak_ref = ref.as_weak();
auto locked = weak_ref.lock();
CHECK(locked);
CHECK(locked.get() == ref.get());
CHECK(locked->value == 333);
}
SUBCASE("lock() returns empty when object destroyed") {
WeakRef<TestObject> weak_ref;
{
auto ref = make_ref<TestObject>(444);
weak_ref = ref.as_weak();
}
// ref goes out of scope, object should be destroyed
auto locked = weak_ref.lock();
CHECK(!locked);
}
SUBCASE("copy and move semantics") {
auto ref = make_ref<TestObject>(666);
WeakRef<TestObject> weak1 = ref.as_weak();
WeakRef<TestObject> weak2 = weak1.copy(); // explicit copy
WeakRef<TestObject> weak3 = std::move(weak1); // move
auto locked2 = weak2.lock();
auto locked3 = weak3.lock();
CHECK(locked2);
CHECK(locked3);
CHECK(locked2->value == 666);
CHECK(locked3->value == 666);
}
}
TEST_CASE("Ref thread safety") {
SUBCASE("concurrent copying") {
const int num_threads = 4;
const int copies_per_thread = 100;
const int test_iterations = 1000;
for (int iter = 0; iter < test_iterations; ++iter) {
auto ref = make_ref<TestObject>(777);
std::vector<std::thread> threads;
std::latch start_latch{num_threads + 1};
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&]() {
start_latch.arrive_and_wait();
for (int j = 0; j < copies_per_thread; ++j) {
auto copy = ref.copy();
CHECK(copy);
CHECK(copy->value == 777);
}
});
}
start_latch.arrive_and_wait();
for (auto &t : threads) {
t.join();
}
CHECK(ref);
CHECK(ref->value == 777);
}
}
}
TEST_CASE("Control block cleanup race condition test") {
// This test specifically targets the race condition where both
// the last strong reference and last weak reference are destroyed
// simultaneously, potentially causing double-free of control block
const int test_iterations = 10000;
// Shared state for passing references between threads
Ref<TestObject> ptr1;
WeakRef<TestObject> ptr2;
auto setup = [&]() {
ptr1 = make_ref<TestObject>(0);
ptr2 = ptr1.as_weak();
};
// Barrier for synchronization - 2 participants (main thread + worker thread)
std::barrier sync_barrier{2};
std::thread worker_thread([&]() {
for (int iter = 0; iter < test_iterations; ++iter) {
// Wait for main thread to create the references
sync_barrier.arrive_and_wait();
// Worker thread destroys the weak reference simultaneously with main
// thread
ptr2.reset();
// Wait for next iteration
sync_barrier.arrive_and_wait();
}
});
for (int iter = 0; iter < test_iterations; ++iter) {
// Create references
setup();
// Both threads are ready - synchronize for simultaneous destruction
sync_barrier.arrive_and_wait();
// Main thread destroys the strong reference at the same time
// as worker thread destroys the weak reference
ptr1.reset();
// Wait for both destructions to complete
sync_barrier.arrive_and_wait();
// Clean up for next iteration
ptr1.reset();
ptr2.reset();
}
worker_thread.join();
// If we reach here without segfault/double-free, the test passes
// The bug would manifest as a crash or memory corruption
}
TEST_CASE("WeakRef prevents circular references") {
SUBCASE("simple weak reference lifecycle") {
WeakRef<TestObject> weak_ref;
// Create object and weak reference
{
auto ref = make_ref<TestObject>(123);
weak_ref = ref.as_weak();
// Should be able to lock while object exists
auto locked = weak_ref.lock();
CHECK(locked);
CHECK(locked->value == 123);
}
// Object destroyed when ref goes out of scope
// Should not be able to lock after object destroyed
auto locked = weak_ref.lock();
CHECK(!locked);
}
SUBCASE("parent-child cycle with WeakRef breaks cycle") {
auto parent = make_ref<Node>(1);
auto child = make_ref<Node>(2);
// Create potential cycle
parent->next = child.copy(); // Strong reference: parent → child
child->parent = parent.as_weak(); // WeakRef: child ⇝ parent (breaks cycle)
CHECK(parent->data == 1);
CHECK(child->data == 2);
CHECK(parent->next == child);
// Verify weak reference works while parent exists
CHECK(child->parent.lock() == parent);
// Clear the only strong reference to parent
parent.reset(); // This should destroy the parent object
// Now child's weak reference should fail to lock since parent is destroyed
CHECK(!child->parent.lock());
}
}
TEST_CASE("Polymorphic Ref conversions") {
SUBCASE("copy construction from derived to base") {
auto derived_ref = make_ref<Derived>(10, 20);
CHECK(derived_ref->get_value() == 30); // 10 + 20
// Convert Ref<Derived> to Ref<Base>
Ref<Base> base_ref = derived_ref.copy();
CHECK(base_ref);
CHECK(base_ref->get_value() == 30); // Virtual dispatch works
CHECK(base_ref->base_value == 10);
// Both should point to same object
CHECK(base_ref.get() == derived_ref.get());
}
SUBCASE("copy assignment from derived to base") {
auto derived_ref = make_ref<Derived>(5, 15);
auto base_ref = make_ref<Base>(100);
// Before assignment
CHECK(base_ref->get_value() == 100);
// Assign derived to base
base_ref = derived_ref.copy();
CHECK(base_ref->get_value() == 20); // 5 + 15
CHECK(base_ref.get() == derived_ref.get());
}
SUBCASE("move construction from derived to base") {
auto derived_ref = make_ref<Derived>(7, 3);
Base *original_ptr = derived_ref.get();
// Move construct base from derived
Ref<Base> base_ref = std::move(derived_ref);
CHECK(base_ref);
CHECK(base_ref->get_value() == 10); // 7 + 3
CHECK(base_ref.get() == original_ptr);
CHECK(!derived_ref); // Original should be empty after move
}
SUBCASE("move assignment from derived to base") {
auto derived_ref = make_ref<Derived>(8, 12);
auto base_ref = make_ref<Base>(200);
Base *derived_ptr = derived_ref.get();
// Move assign
base_ref = std::move(derived_ref);
CHECK(base_ref);
CHECK(base_ref->get_value() == 20); // 8 + 12
CHECK(base_ref.get() == derived_ptr);
CHECK(!derived_ref); // Should be empty after move
}
SUBCASE("multiple inheritance levels") {
auto another_derived = make_ref<AnotherDerived>(6, 4);
CHECK(another_derived->get_value() == 24); // 6 * 4
// Convert to base
Ref<Base> base_ref = another_derived.copy();
CHECK(base_ref->get_value() == 24); // Virtual dispatch
CHECK(base_ref.get() == another_derived.get());
}
}
TEST_CASE("Polymorphic WeakRef conversions") {
SUBCASE("WeakRef copy construction from derived to base") {
auto derived_ref = make_ref<Derived>(3, 7);
// Create WeakRef<Derived>
WeakRef<Derived> weak_derived = derived_ref.as_weak();
// Convert to WeakRef<Base>
WeakRef<Base> weak_base = weak_derived.copy();
// Both should lock to same object
auto locked_derived = weak_derived.lock();
auto locked_base = weak_base.lock();
CHECK(locked_derived);
CHECK(locked_base);
CHECK(locked_derived.get() == locked_base.get());
CHECK(locked_base->get_value() == 10); // 3 + 7
}
SUBCASE("WeakRef copy assignment from derived to base") {
auto derived_ref = make_ref<Derived>(4, 6);
auto base_ref = make_ref<Base>(999);
WeakRef<Derived> weak_derived = derived_ref.as_weak();
WeakRef<Base> weak_base = base_ref.as_weak();
// Assign derived weak ref to base weak ref
weak_base = weak_derived.copy();
auto locked = weak_base.lock();
CHECK(locked);
CHECK(locked->get_value() == 10); // 4 + 6
CHECK(locked.get() == derived_ref.get());
}
SUBCASE("WeakRef from Ref<Derived> to WeakRef<Base>") {
auto derived_ref = make_ref<Derived>(2, 8);
// Create WeakRef<Base> directly from Ref<Derived>
WeakRef<Base> weak_base = derived_ref.as_weak();
auto locked = weak_base.lock();
CHECK(locked);
CHECK(locked->get_value() == 10); // 2 + 8
CHECK(locked.get() == derived_ref.get());
}
SUBCASE("WeakRef move operations") {
auto derived_ref = make_ref<Derived>(1, 9);
WeakRef<Derived> weak_derived = derived_ref.as_weak();
// Move construct
WeakRef<Base> weak_base = std::move(weak_derived);
// Original should be empty, new should work
CHECK(!weak_derived.lock());
auto locked = weak_base.lock();
CHECK(locked);
CHECK(locked->get_value() == 10); // 1 + 9
}
}
TEST_CASE("Polymorphic edge cases") {
SUBCASE("empty Ref conversions") {
Ref<Derived> empty_derived;
CHECK(!empty_derived);
// Convert empty derived to base
Ref<Base> empty_base = empty_derived.copy();
CHECK(!empty_base);
// Move empty derived to base
Ref<Base> moved_base = std::move(empty_derived);
CHECK(!moved_base);
}
SUBCASE("empty WeakRef conversions") {
WeakRef<Derived> empty_weak_derived;
CHECK(!empty_weak_derived.lock());
// Convert empty weak derived to weak base
WeakRef<Base> empty_weak_base = empty_weak_derived.copy();
CHECK(!empty_weak_base.lock());
}
SUBCASE("mixed Ref and WeakRef conversions") {
auto derived_ref = make_ref<Derived>(5, 5);
// Ref<Derived> → WeakRef<Base>
WeakRef<Base> weak_base_from_ref = derived_ref.as_weak();
// WeakRef<Base> → Ref<Base> via lock
auto base_ref_from_weak = weak_base_from_ref.lock();
CHECK(base_ref_from_weak);
CHECK(base_ref_from_weak->get_value() == 10); // 5 + 5
CHECK(base_ref_from_weak.get() == derived_ref.get());
}
SUBCASE("multiple inheritance pointer address bug test") {
auto multi_ref = make_ref<MultipleInheritance>(42);
// Get pointers to different base classes - these will have different
// addresses
Interface1 *interface1_ptr = multi_ref.get();
Interface2 *interface2_ptr = multi_ref.get();
// Verify that pointers are indeed different (demonstrating the issue)
CHECK(static_cast<void *>(interface1_ptr) !=
static_cast<void *>(interface2_ptr));
// Create WeakRef to Interface2 (which has a different pointer address)
WeakRef<Interface2> weak_interface2 = multi_ref.as_weak();
// Lock should return the correct Interface2 pointer, not miscalculated one
auto locked_interface2 = weak_interface2.lock();
CHECK(locked_interface2);
CHECK(locked_interface2.get() ==
interface2_ptr); // This might fail due to the bug!
CHECK(locked_interface2->get_interface2() == 2);
// Also test Interface1
WeakRef<Interface1> weak_interface1 = multi_ref.as_weak();
auto locked_interface1 = weak_interface1.lock();
CHECK(locked_interface1);
CHECK(locked_interface1.get() == interface1_ptr); // This might also fail!
CHECK(locked_interface1->get_interface1() == 1);
}
}
// Should be run with asan or valgrind
TEST_CASE("Self-referencing WeakRef pattern") {
struct AmIAlive {
volatile int x;
~AmIAlive() { x = 0; }
};
struct SelfReferencing {
AmIAlive am;
WeakRef<SelfReferencing> self_;
};
auto x = make_ref<SelfReferencing>();
x->self_ = x.as_weak();
}
+187
View File
@@ -0,0 +1,187 @@
#include "config.hpp"
#include "connection.hpp"
#include "connection_handler.hpp"
#include "server.hpp"
#include <atomic>
#include <doctest/doctest.h>
#include <latch>
#include <string_view>
#include <thread>
struct Event {
void wait() { done_.wait(); }
void set() {
if (!set_.exchange(true, std::memory_order_relaxed)) {
done_.count_down();
}
}
private:
std::latch done_{1};
std::atomic<bool> set_;
};
struct EchoHandler : ConnectionHandler {
Arena arena;
std::span<std::string_view> reply;
WeakRef<MessageSender> wconn;
Event done;
void on_data_arrived(std::string_view data, Connection &conn) override {
reply = arena.allocate_span<std::string_view>(1);
reply[0] = arena.copy_string(data);
wconn = conn.get_weak_ref();
CHECK(wconn.lock());
done.set();
}
};
TEST_CASE("Echo test") {
EchoHandler handler;
weaseldb::Config config;
auto server = Server::create(config, handler, {});
int fd = server->create_local_connection();
auto runThread = std::thread{[&]() { server->run(); }};
int w = write(fd, "hello", 5);
REQUIRE(w == 5);
handler.done.wait();
if (auto conn = handler.wconn.lock()) {
// Cast to Connection* to access append_bytes (not available on
// MessageSender)
auto *conn_ptr = static_cast<Connection *>(conn.get());
conn_ptr->append_bytes(std::exchange(handler.reply, {}),
std::move(handler.arena), ConnectionShutdown::None);
} else {
REQUIRE(false);
}
char buf[6];
buf[5] = 0;
int r = read(fd, buf, 5);
REQUIRE(r == 5);
CHECK(std::string(buf) == "hello");
close(fd);
server->shutdown();
runThread.join();
}
struct ShutdownTestHandler : ConnectionHandler {
Arena arena;
std::span<std::string_view> reply;
WeakRef<MessageSender> wconn;
Event received_data;
Event connection_closed_latch;
ConnectionShutdown shutdown_mode = ConnectionShutdown::None;
std::atomic<bool> connection_closed{false};
void on_data_arrived(std::string_view data, Connection &conn) override {
reply = arena.allocate_span<std::string_view>(1);
reply[0] = arena.copy_string(data);
wconn = conn.get_weak_ref();
received_data.set();
}
void on_connection_closed(Connection &) override {
connection_closed = true;
connection_closed_latch.set();
}
};
TEST_CASE("Connection shutdown write-only mode") {
ShutdownTestHandler handler;
handler.shutdown_mode = ConnectionShutdown::WriteOnly;
weaseldb::Config config;
auto server = Server::create(config, handler, {});
int fd = server->create_local_connection();
auto runThread = std::thread{[&]() { server->run(); }};
// Send data to trigger handler
int w = write(fd, "test", 4);
REQUIRE(w == 4);
handler.received_data.wait();
// Send response with write shutdown
if (auto conn = handler.wconn.lock()) {
auto *conn_ptr = static_cast<Connection *>(conn.get());
conn_ptr->append_bytes(std::exchange(handler.reply, {}),
std::move(handler.arena),
ConnectionShutdown::WriteOnly);
} else {
REQUIRE(false);
}
// Read the response
char buf[5];
buf[4] = 0;
int r = read(fd, buf, 4);
REQUIRE(r == 4);
CHECK(std::string(buf) == "test");
// After write shutdown, we should get EOF when trying to read more
char extra_buf[1];
int eof_result = read(fd, extra_buf, 1);
CHECK(eof_result == 0); // EOF indicates successful write shutdown
// Connection should still be alive (not closed) after write shutdown
// We can verify this by checking that we can still write to the socket
int write_result = write(fd, "x", 1);
CHECK(write_result == 1); // Should succeed - connection still alive
CHECK(handler.connection_closed.load() ==
false); // Connection should still be alive
close(fd);
server->shutdown();
runThread.join();
}
TEST_CASE("Connection shutdown full mode") {
ShutdownTestHandler handler;
handler.shutdown_mode = ConnectionShutdown::Full;
weaseldb::Config config;
auto server = Server::create(config, handler, {});
int fd = server->create_local_connection();
auto runThread = std::thread{[&]() { server->run(); }};
// Send data to trigger handler
int w = write(fd, "test", 4);
REQUIRE(w == 4);
handler.received_data.wait();
// Send response with full shutdown
if (auto conn = handler.wconn.lock()) {
auto *conn_ptr = static_cast<Connection *>(conn.get());
conn_ptr->append_bytes(std::exchange(handler.reply, {}),
std::move(handler.arena), ConnectionShutdown::Full);
} else {
REQUIRE(false);
}
// Read the response - connection should close after this
char buf[5];
buf[4] = 0;
int r = read(fd, buf, 4);
REQUIRE(r == 4);
CHECK(std::string(buf) == "test");
// Connection should be closed by server (full shutdown)
char extra_buf[1];
int close_result = read(fd, extra_buf, 1);
CHECK(close_result == 0); // EOF indicates connection was closed
// Wait for connection closed callback to be called
handler.connection_closed_latch.wait();
CHECK(handler.connection_closed.load() == true);
close(fd);
server->shutdown();
runThread.join();
}
-110
View File
@@ -1,110 +0,0 @@
#include "../src/thread_pipeline.hpp"
#include "config.hpp"
#include "connection.hpp"
#include "perfetto_categories.hpp"
#include "server.hpp"
#include <cstring>
#include <doctest/doctest.h>
#include <thread>
// Perfetto static storage for tests
PERFETTO_TRACK_EVENT_STATIC_STORAGE();
struct Message {
std::unique_ptr<Connection> conn;
std::string data;
bool done;
};
struct EchoHandler : public ConnectionHandler {
private:
StaticThreadPipeline<Message, WaitStrategy::WaitIfStageEmpty, 1> &pipeline;
public:
explicit EchoHandler(
StaticThreadPipeline<Message, WaitStrategy::WaitIfStageEmpty, 1>
&pipeline)
: pipeline(pipeline) {}
void on_data_arrived(std::string_view data,
std::unique_ptr<Connection> &conn_ptr) override {
assert(conn_ptr);
auto guard = pipeline.push(1, true);
for (auto &message : guard.batch) {
message.conn = std::move(conn_ptr);
message.data = data;
message.done = false;
}
}
};
TEST_CASE(
"Server correctly handles connection ownership transfer via pipeline") {
weaseldb::Config config;
config.server.io_threads = 1;
config.server.epoll_instances = 1;
StaticThreadPipeline<Message, WaitStrategy::WaitIfStageEmpty, 1> pipeline{10};
EchoHandler handler{pipeline};
auto echoThread = std::thread{[&]() {
for (;;) {
auto guard = pipeline.acquire<0, 0>();
for (auto &message : guard.batch) {
bool done = message.done;
if (done) {
return;
}
assert(message.conn);
message.conn->append_message(message.data);
Server::release_back_to_server(std::move(message.conn));
}
}
}};
// Create server with NO listen sockets (empty vector)
auto server = Server::create(config, handler, {});
std::thread server_thread([&server]() { server->run(); });
// Create local connection
int client_fd = server->create_local_connection();
REQUIRE(client_fd > 0);
// Write some test data
const char *test_message = "Hello, World!";
ssize_t bytes_written;
do {
bytes_written = write(client_fd, test_message, std::strlen(test_message));
} while (bytes_written == -1 && errno == EINTR);
REQUIRE(bytes_written == std::strlen(test_message));
// Read the echoed response
char buffer[1024] = {0};
ssize_t bytes_read;
do {
bytes_read = read(client_fd, buffer, sizeof(buffer) - 1);
} while (bytes_read == -1 && errno == EINTR);
if (bytes_read == -1) {
perror("read failed");
}
REQUIRE(bytes_read == std::strlen(test_message));
// Verify we got back exactly what we sent
CHECK(std::string(buffer, bytes_read) == std::string(test_message));
// Cleanup
int e = close(client_fd);
if (e == -1 && errno != EINTR) {
perror("close client_fd");
std::abort();
}
server->shutdown();
server_thread.join();
{
auto guard = pipeline.push(1, true);
for (auto &message : guard.batch) {
message.done = true;
}
}
echoThread.join();
}
+40 -26
View File
@@ -2,69 +2,83 @@
## Summary
WeaselDB achieved 1.3M requests/second throughput using a two-stage ThreadPipeline with futex wake optimization, delivering 550ns serial CPU time per request while maintaining 0% CPU usage when idle. Higher serial CPU time means more CPU budget available for serial processing.
WeaselDB's /ok health check endpoint achieves approximately 825k requests/second with 740ns of configurable CPU work per request through the 4-stage commit pipeline, while maintaining 0% CPU usage when idle. The configurable CPU work serves both as a health check (validating the full pipeline) and as a benchmarking tool for measuring per-request processing capacity.
> **Note on historical numbers**: An earlier version of this report claimed 1.0M requests/second at 740ns serial CPU work. That measurement was made using a design that transferred unique ownership of connections through the pipeline. The current server-owned connection model adds per-request synchronization overhead that lowers the raw /ok throughput, but enables streaming endpoints such as `/v1/subscribe` and safer async response handling.
## Performance Metrics
### Throughput
- **1.3M requests/second** over unix socket
- **~825k requests/second** /ok health check endpoint (4-stage commit pipeline)
- 8 I/O threads with 8 epoll instances
- Load tester used 12 network threads
- Max latency: 4ms out of 90M requests
- Load tester used 10 network threads
- **0% CPU usage when idle** (optimized futex wake implementation)
### Threading Architecture
- Two-stage pipeline: Stage-0 (noop) → Stage-1 (connection return)
- **Four-stage commit pipeline**: Sequence → Resolve → Persist → Release
- Lock-free coordination using atomic ring buffer
- **Optimized futex wake**: Only wake on final pipeline stage
- Each request "processed" serially on single thread
- Configurable CPU work performed serially in resolve stage
### Performance Characteristics
**Optimized Pipeline Mode**:
- **Throughput**: 1.3M requests/second
- **Serial CPU time per request**: 550ns (validated with nanobench)
- **Theoretical maximum serial CPU time**: 769ns (1,000,000,000ns ÷ 1,300,000 req/s)
- **Serial efficiency**: 71.5% (550ns ÷ 769ns)
**Health Check Pipeline (/ok endpoint)**:
- **Throughput**: ~825k requests/second (sustained over a 30-second run)
- **Configurable CPU work**: 740ns (4000 iterations, validated with nanobench)
- **Theoretical maximum CPU time at this throughput**: ~1212ns (1,000,000,000ns ÷ 825,000 req/s)
- **CPU work efficiency**: ~61% (740ns ÷ 1212ns)
- **Pipeline stages**: Sequence (noop) → Resolve (CPU work) → Persist (response) → Release (cleanup)
- **CPU usage when idle**: 0%
### Key Optimizations
**Futex Wake Reduction**:
- **Previous approach**: Futex wake at every pipeline stage (10% CPU overhead)
- **Optimized approach**: Futex wake only at final stage to wake producers. Stages now do their futex wait on the beginning of the pipeline instead of the previous stage.
- **Result**: 23% increase in serial CPU budget (396ns → 488ns)
- **Benefits**: Higher throughput per CPU cycle + idle efficiency
**CPU-Friendly Spin Loop**:
- **Added**: `_mm_pause()` intrinsics in polling loop to reduce power consumption and improve hyperthreading efficiency
- **Maintained**: 100,000 spin iterations necessary to prevent thread descheduling
- **Result**: Same throughput with more efficient spinning
**Stage-0 Batch Size Optimization**:
- **Changed**: Stage-0 max batch size from unlimited to 1
**Resolve Batch Size Optimization**:
- **Changed**: Resolve max batch size from unlimited to 1
- **Mechanism**: Single-item processing checks for work more frequently, keeping the thread in fast coordination paths instead of expensive spin/wait cycles
- **Profile evidence**: Coordination overhead reduced from ~11% to ~5.6% CPU time
- **Result**: Additional 12.7% increase in serial CPU budget (488ns → 550ns)
- **Overall improvement**: 38.9% increase from baseline (396ns → 550ns)
### Request Flow
**Health Check Pipeline** (/ok endpoint):
```
I/O Threads (8) → HttpHandler::on_batch_complete() → ThreadPipeline
I/O Threads (8) → HttpHandler::on_batch_complete() → Commit Pipeline
↑ ↓
| Stage 0: Noop thread
| (550ns serial CPU per request)
| (batch size: 1)
| Stage 0: Sequence (noop)
| ↓
| Stage 1: Connection return
| Stage 1: Resolve (740ns CPU work)
| (spend_cpu_cycles(4000))
| ↓
| Stage 2: Persist (generate response)
| (send "OK" response)
| ↓
| Stage 3: Release (wake I/O threads)
| (optimized futex wake)
| ↓
└─────────────────────── Server::release_back_to_server()
└─────────────────────── I/O threads send response to client
```
## Test Configuration
- Server: test_config.toml with 8 io_threads, 8 epoll_instances
- Load tester: ./load_tester --network-threads 12
- Build: ninja
- Command: ./weaseldb --config test_config.toml
- Server: test_benchmark_config.toml with 8 io_threads, 8 epoll_instances
- Configuration: `ok_resolve_iterations = 4000` (740ns CPU work)
- Load tester: targeting /ok endpoint, 10 network threads, 8 connect threads, 2000 concurrent connections, 500 requests per connection
- Benchmark validation: ./bench_cpu_work 4000
- Build: ninja Release
- Command: ./weaseldb --config test_benchmark_config.toml
+14 -7
View File
@@ -3,16 +3,19 @@
## 📋 Planned Tasks
### Core Database Features
- [ ] Design commit pipeline architecture with three-stage processing
- [ ] Stage 1: Version assignment and precondition validation thread
- [ ] Stage 2: Transaction persistence and subscriber streaming thread
- [ ] Stage 3: Connection return to server thread
- [ ] Design commit pipeline architecture with four-stage processing
- [ ] Stage 0: Sequence assignment and request validation
- [ ] Stage 1: Precondition resolution and conflict detection
- [ ] Stage 2: Transaction persistence and subscriber streaming
- [ ] Stage 3: Response generation and connection cleanup
- [ ] Use ThreadPipeline for inter-stage communication
- [ ] Design persistence interface for pluggable storage backends (S3, local disk)
- [ ] Integrate https://git.weaselab.dev/weaselab/conflict-set for optimistic concurrency control
- [ ] Design and architect the subscription component for change streams
### API Endpoints Implementation
- [ ] Implement `GET /v1/version` endpoint to return latest committed version and leader
- [ ] Implement `POST /v1/commit` endpoint for transaction submission with precondition validation
- [ ] Implement `GET /v1/status` endpoint for commit request status lookup by request_id
@@ -23,8 +26,10 @@
- [ ] Implement `DELETE /v1/retention/<policy_id>` endpoint for retention policy removal
### Infrastructure & Tooling
- [ ] Implement thread-safe Prometheus metrics library and serve `GET /metrics` endpoint
- [x] Implement thread-safe Prometheus metrics library and serve `GET /metrics` endpoint
- [ ] Implement gperf-based HTTP routing for efficient request dispatching
- [ ] Replace nlohmann/json with simdjson DOM API in parser comparison benchmarks
- [ ] Implement HTTP client for S3 interactions
- [ ] Design `HttpClient` class following WeaselDB patterns (factory creation, arena allocation, RAII)
- [ ] Implement connection pool with configurable limits (max connections, idle timeout)
@@ -42,7 +47,7 @@
- [ ] Support for HTTP redirects (3xx responses) with redirect limits
- [ ] SSL/TLS support using OpenSSL for HTTPS connections
- [ ] Request/response logging and metrics integration
- [ ] Memory-efficient design with zero-copy where possible
- [ ] Memory-efficient design minimizing unnecessary copying
- [ ] Implement fake in-process S3 service using separate Server instance with S3 ConnectionHandler
- [ ] Use create_local_connection to get fd for in-process communication
- [ ] Implement `ListObjectsV2` API for object enumeration
@@ -53,6 +58,7 @@
- [ ] Implement `DeleteObjects` for batch object deletion
### Client Libraries
- [ ] Implement high-level Python client library for WeaselDB REST API
- [ ] Wrap `/v1/version`, `/v1/commit`, `/v1/status` endpoints
- [ ] Handle `/v1/subscribe` SSE streaming with reconnection logic
@@ -63,6 +69,7 @@
- [ ] Provide CLI tooling for database administration
### Testing & Validation
- [ ] Build out-of-process API test suite using client library over real TCP
- [ ] Test all `/v1/version`, `/v1/commit`, `/v1/status` endpoints
- [ ] Test `/v1/subscribe` Server-Sent Events streaming
@@ -78,6 +85,6 @@
- [x] Built streaming JSON parser for commit requests with high-performance parsing
- [x] Implemented HTTP server with multi-threaded networking using multiple epoll instances
- [x] Created threading model with pipeline for serial request processing for optimistic concurrency control
- [x] Designed connection ownership transfer system to enable the serial processing model
- [x] Implemented server-owned connection model with WeakRef pattern for safe concurrent access
- [x] Implemented arena-per-connection memory model for clean memory lifetime management
- [x] Built TOML configuration system for server settings
+4 -2
View File
@@ -66,6 +66,8 @@ def check_snake_case_violations(filepath, check_new_only=True):
exclusions = [
# C++ standard library and common libraries
r"\b(std::|weaseljson|simdutf|doctest)",
# Nanobench library API (external camelCase API)
r"\b(nanobench::|doNotOptimizeAway|minEpochIterations)\b",
# Template parameters and concepts
r"\b[A-Z][a-zA-Z0-9_]*\b",
# Class/struct names (PascalCase is correct)
@@ -76,7 +78,7 @@ def check_snake_case_violations(filepath, check_new_only=True):
# Common HTTP parser callback names (external API)
r"\b(onUrl|onHeaderField|onHeaderFieldComplete|onHeaderValue|onHeaderValueComplete|onHeadersComplete|onBody|onMessageComplete)\b",
# Known legacy APIs we can't easily change
r"\b(user_data|get_arena|append_message)\b",
r"\b(user_data|get_arena|send_response)\b",
]
try:
@@ -155,7 +157,7 @@ def main():
if violations:
for v in violations:
print(f"\n{filepath}:{v['line']}:{v['column']}")
print(f"\n{filepath}:{v['line']}:{v['column']}:")
print(f" {v['type']} '{v['camelCase']}' should be '{v['snake_case']}'")
print(f" Context: {v['context']}")
total_violations += len(violations)
+1 -1
View File
@@ -10,7 +10,7 @@
struct ArenaDebugger {
const CommitRequest &commit_request;
const ArenaAllocator &arena;
const Arena &arena;
std::unordered_set<const void *> referenced_addresses;
explicit ArenaDebugger(const CommitRequest &cr)
+13 -12
View File
@@ -248,7 +248,7 @@ struct Connection {
}
// Match server's connection state management
bool hasMessages() const { return !request.empty(); }
bool has_messages() const { return !request.empty(); }
bool error = false;
~Connection() {
@@ -297,10 +297,10 @@ struct Connection {
}
}
bool writeBytes() {
bool write_bytes() {
for (;;) {
assert(!request.empty());
int w = write(fd, request.data(), request.size());
int w = send(fd, request.data(), request.size(), MSG_NOSIGNAL);
if (w == -1) {
if (errno == EINTR) {
continue;
@@ -401,6 +401,7 @@ private:
double current_min = g_min_latency.load(std::memory_order_relaxed);
while (latency < current_min &&
!g_min_latency.compare_exchange_weak(current_min, latency,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
// Retry if another thread updated min_latency
}
@@ -408,6 +409,7 @@ private:
double current_max = g_max_latency.load(std::memory_order_relaxed);
while (latency > current_max &&
!g_max_latency.compare_exchange_weak(current_max, latency,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
// Retry if another thread updated max_latency
}
@@ -608,7 +610,6 @@ int main(int argc, char *argv[]) {
}
printf("\n");
signal(SIGPIPE, SIG_IGN);
signal(SIGTERM, signal_handler);
signal(SIGINT, signal_handler);
@@ -637,7 +638,7 @@ int main(int argc, char *argv[]) {
int epollfd = g_epoll_fds[i]; // Each thread uses its own epoll instance
pthread_setname_np(pthread_self(),
("network-" + std::to_string(i)).c_str());
while (g_connect_threads.load() != 0) {
while (g_connect_threads.load(std::memory_order_acquire) != 0) {
struct epoll_event events[256]; // Use a reasonable max size
int batch_size = std::min(int(sizeof(events) / sizeof(events[0])),
g_config.event_batch_size);
@@ -671,7 +672,7 @@ int main(int argc, char *argv[]) {
continue; // Let unique_ptr destructor clean up
}
if (events[i].events & EPOLLOUT) {
bool finished = conn->writeBytes();
bool finished = conn->write_bytes();
if (conn->error) {
continue;
}
@@ -696,7 +697,7 @@ int main(int argc, char *argv[]) {
// Transfer back to epoll instance. This thread or another thread
// will wake when fd is ready
if (conn->hasMessages()) {
if (conn->has_messages()) {
events[i].events = EPOLLOUT | EPOLLONESHOT;
} else {
events[i].events = EPOLLIN | EPOLLONESHOT;
@@ -746,15 +747,15 @@ int main(int argc, char *argv[]) {
// Try to write once in the connect thread before handing off to network
// threads
assert(conn->hasMessages());
bool writeFinished = conn->writeBytes();
assert(conn->has_messages());
bool write_finished = conn->write_bytes();
if (conn->error) {
continue; // Connection failed, destructor will clean up
}
// Determine the appropriate epoll events based on write result
struct epoll_event event{};
if (writeFinished) {
if (write_finished) {
// All data was written, wait for response
int shutdown_result = shutdown(conn->fd, SHUT_WR);
if (shutdown_result == -1) {
@@ -764,7 +765,7 @@ int main(int argc, char *argv[]) {
event.events = EPOLLIN | EPOLLONESHOT;
} else {
event.events =
(conn->hasMessages() ? EPOLLOUT : EPOLLIN) | EPOLLONESHOT;
(conn->has_messages() ? EPOLLOUT : EPOLLIN) | EPOLLONESHOT;
}
// Add to a round-robin selected epoll instance to distribute load
@@ -779,7 +780,7 @@ int main(int argc, char *argv[]) {
continue;
}
}
g_connect_threads.fetch_sub(1);
g_connect_threads.fetch_sub(1, std::memory_order_release);
});
}