Skip to content

Release v1.6.1 - #7

Merged
libraz merged 42 commits into
mainfrom
develop
May 6, 2026
Merged

Release v1.6.1#7
libraz merged 42 commits into
mainfrom
develop

Conversation

@libraz

@libraz libraz commented May 6, 2026

Copy link
Copy Markdown
Owner

Summary

Release v1.6.1 — sizable maintenance release focused on lifecycle and concurrency hardening, HTTP search API extensions, and client/CLI correctness.

Highlights:

  • HTTP JSON search APIsort, fuzzy, and highlight options aligned with the TCP protocol
  • Concurrency hardening — Reactor, kqueue, ConnectionAcceptor, SnapshotScheduler, SyncOperationManager, DumpProgress, PeriodicWorker race fixes
  • Lifecycle correctness — DUMP/HTTP/SYNC startup-shutdown races; lock-order deadlock in cache; replication-pause counter
  • Client/CLI rewritemygram-cli now delegates to MygramClient; eight independent client correctness bugs fixed
  • Network hardening — TCP_NODELAY, configurable HTTP body size, idle-connection reaper, shared rate limiter across TCP/HTTP
  • HTTP search input validation — Reject query smuggling via reserved-clause keywords and unsafe table names
  • Build fixes — Restored mygramdb_index link for bm25_scorer_test; declared mygramdb_cache dependency on mygramdb_mysql (fixed Linux GCC link-order issues)

40 commits, 161 files changed, +17,382 / -3,621 LOC.

See docs/releases/v1.6.1.md for full details.

Test plan

  • Develop CI green (run 25454912264)
  • Main CI green after merge
  • Release workflow produces RPM/DEB packages
  • Docker release workflow publishes ghcr.io/libraz/mygram-db:1.6.1

libraz added 30 commits April 15, 2026 15:41
…gnostics

Strengthen the synonym dictionary for Japanese workloads by improving
loading diagnostics and adding comprehensive test coverage across both
unit and search-pipeline levels.

Synonym dictionary improvements (synonym_dictionary.cpp / .h):
- Preserve raw tokens before normalization to enable meaningful warning
  messages when tokens collapse or conflict
- Emit structured log event "synonym_group_collapsed" when all tokens in
  a group normalize to the same value, including the raw term preview
  and source line number
- Emit "synonym_group_term_conflict" when too few non-conflicting terms
  remain to form a usable group, listing the conflicting terms
- Add ForEachTerm() method (shared-lock safe) so callers can inspect
  every loaded term without exposing internal data structures

Startup diagnostics (server_orchestrator.cpp):
- After loading a synonym dictionary, call ForEachTerm() to identify
  terms that are too short to produce any n-grams given the table's
  ngram_size / kanji_ngram_size configuration
- Emit "synonym_variant_unreachable" warning for each such term so
  operators are alerted to silently dead entries at startup rather than
  at query time

Unit tests (tests/query/synonym_dictionary_test.cpp):
- Add RealNormalizerSynonymDictionaryTest fixture backed by a real Index
  normalizer (NFKC + width=half + lower=true) to match production
- Add ForEachTermIteratesAllTerms to cover the new ForEachTerm API
- Add seven Japanese normalization tests (TC-SD-01 through TC-SD-07)
  covering half-width kana collapse, hiragana/katakana distinctness,
  kana+kanji three-element groups, full-width ASCII folding, dedup
  after partial collapse, multiple independent groups, and first-wins
  conflict resolution

E2E pipeline tests (tests/server/search_pipeline_synonym_jp_test.cpp):
- New JapaneseSynonymPipelineTest fixture using ExecuteFullPipeline
  with ngram_size=3 and kanji_ngram_size=2
- TC-SP-01: Hiragana query expands to katakana and kanji documents
- TC-SP-02: Kanji query returns the same set via expansion
- TC-SP-03: Half-width kana normalizes to full-width and reaches
  the matching synonym group (kana + ASCII alternative)
- TC-SP-04: Kanji bigrams (kanji_ngram_size=2) do not break expansion
  when synonyms share a leading bigram
- TC-SP-05: OR within synonym group, AND across multiple query terms
- TC-SP-06: verify_text="all" post-filter accepts synonym-matched docs
  via PostFilterByTextWithSynonyms
- CMakeLists.txt wired up as search_pipeline_synonym_jp_test target
…/CRLF/port bugs

Refactor mygram-cli.cpp to delegate socket I/O, hostname resolution,
response framing, and timeouts to mygramdb::client::MygramClient, removing
~500 lines of duplicate implementation. Fix several correctness bugs
discovered during the rewrite.

Bug fixes:
- Hostname resolution: replace inet_pton (IPv4-literal only) with
  getaddrinfo via MygramClient, so -h localhost now works
- Response truncation: replace single 64 KB recv() with the client
  library's full-response loop using IsResponseComplete; large
  SEARCH/INFO responses no longer get cut off
- REPLICATION STATUS CRLF: old code searched for literal escape sequences
  "\\r\\n" instead of the actual two-byte sequence, leaving raw CR/LF on
  screen; replaced with a shared NormalizeCrlf helper
- INFO/REPLICATION/CACHE_STATS: now strip the trailing END sentinel and
  the leading blank line from the OK <TYPE>\r\n\r\n... framing
- Port validation: static_cast<uint16_t>(stoi(...)) silently truncated
  out-of-range values (port 70000 -> 4464); now rejects values outside
  [1, 65535], non-numeric input, and trailing-garbage parses
- SIGPIPE: process now ignores SIGPIPE so a closed server connection
  raises EPIPE on send() instead of killing the CLI
- Arg joining: whitespace-containing args are now quoted before being
  sent over the wire (SEARCH articles "hello world" -> two tokens)
- SO_SNDTIMEO / Unix-socket RCVTIMEO: set on both TCP and Unix paths
  via the client library (was TCP-only before)

New response handlers (previously fell through to "Unknown response"):
- OK CONFIG, OK FACET, OK CACHE_STATS / CACHE_CLEARED / CACHE_ENABLED /
  CACHE_DISABLED, OK SYNC STARTED|STOPPED, OK DUMP_STARTED /
  DUMP_VERIFIED / DUMP_INFO / DUMP_STATUS, OK OPTIMIZED, +OK body

Extracted helpers:
- ToUpper, Trim, NormalizeCrlf, StripTrailingEndMarker, QuoteArgIfNeeded,
  StartsWith, JoinArgsForCommand, PrintHeaderAndBody, FormatMultiLineBody,
  PrintSearchOrCountResponse, PrintConnectionHints
- ParseArguments now returns a testable ParseResult struct
- Prefix-length constants imported from server/protocol_constants.h

UX improvements:
- Tab completion adds FACET, CACHE, DUMP, SYNC, OPTIMIZE; removes
  literal placeholder candidates like "<column_name>"
- Help text uses SORT (actual protocol keyword) instead of ORDER BY
- New --version / -V flag
- Trivial commands (help/quit/exit) no longer pollute readline history
- Connection error hints cover hostname-resolution failures and missing
  Unix socket files

Testing:
- ~50 new/rewritten test cases in mygram_cli_test.cpp
- New: string-helper unit tests (ToUpper, Trim, NormalizeCrlf,
  StripTrailingEndMarker, QuoteArgIfNeeded, JoinArgsForCommand)
- New: ParsePort tests (valid range, out-of-range, non-numeric, trailing
  garbage)
- New: ParseArguments tests for every flag including --version / --help
- New: response pretty-print coverage for CONFIG, FACET, CACHE_*,
  SYNC_*, DUMP_*, OPTIMIZED, +OK
- New: regression test for the REPLICATION CRLF bug
- New: INFO normalization and END-marker stripping test
- src/cli/CMakeLists.txt: link mygramclient_static + mygramdb_utils
- tests/cli/CMakeLists.txt: same, plus CMAKE_BINARY_DIR/src include for
  the generated version.h header
- All 2867 fast-tier tests pass (ctest -LE "SLOW|LOAD")
SendCommand hung until the per-socket timeout for CACHE_STATS, DUMP_INFO,
and DUMP_STATUS responses because IsResponseComplete treated their
"OK CACHE_STATS\r\n...\r\nEND\r\n" wire format as unrecognised and kept
accumulating data waiting for the double-CRLF that never arrives.

- Add CACHE_STATS, DUMP_INFO, DUMP_STATUS to the END-marker branch in
  IsResponseComplete (src/client/protocol_detection.h)
- DUMP_INFO first-line carries an optional filepath suffix, so detection
  uses prefix matching (first_crlf >= 12) rather than exact length
- Extract EndsWith helper to eliminate repeated bounds-check boilerplate
  for both the "END\r\n" and "\r\n\r\n" terminal patterns
- Update function-level doc comment to name all six END-terminated commands
…earchExpression

SimplifySearchExpression returned false for any expression that had no
required terms (+ prefix), even when the expression was a valid OR
sub-expression such as "python OR ruby" or "(a OR b)". Callers that
relied on this helper (e.g. the CLI's smart-query mode) silently fell
back to a raw string, bypassing term splitting and NOT handling.

- When required_terms is empty but raw_expression is non-empty, surface
  the OR sub-expression as main_term wrapped in parentheses so it is
  valid when AND-composed by the caller; already-parenthesized inputs
  are not double-wrapped
- Fix docstring examples: ToQueryString always wraps bare OR expressions
  in parentheses; the old example claimed unparenthesized output
- Add four new tests: SimplifyOrOnly, SimplifyParenthesizedOnly,
  SimplifyMixed, ToQueryStringWrapsOrInParens
Addresses eight independent bugs found in code review. All 3091 fast
tests pass with these fixes.

DEBUG response parsing (SearchWithDebugInfo, CountWithDebugInfo):
- Server emits "key: value\r\n" lines; client expected whitespace-
  tokenised "key=value" pairs, so all debug fields were always zero
- The literal "#" from "# DEBUG" leaked into resp.results as a spurious
  primary key entry
- Replace token-based ParseDebugInfo with a line-based parser using the
  new ParseColonKeyValueLines helper; split Search/Count main body from
  debug section via SplitDebugBlock before parsing either

GetReplicationStatus parsing (GetReplicationStatus):
- Same colon-vs-equals mismatch; running was always false, gtid always
  empty; switch to ParseColonKeyValueLines and map current_gtid -> gtid
- Add processed_events and queue_size fields to ReplicationStatus struct

INFO key mapping (Info, InfoMultiLineComplete):
- active_connections was always 0 because the client looked for key
  "active_connections" but the server emits "connected_clients"
- index_size_bytes was always 0 because the server key is "used_memory_bytes"

Connect timeout (ConnectTimeoutOnUnreachableHost):
- SO_RCVTIMEO/SO_SNDTIMEO only govern send/recv, not connect(); a
  connection to an unreachable host blocked for the full OS default
  (~75s) ignoring timeout_ms
- Add ConnectWithTimeout helper: set O_NONBLOCK, call connect(), poll()
  for POLLOUT with the configured timeout, then restore blocking mode;
  ApplySocketTimeouts sets SO_RCVTIMEO/SO_SNDTIMEO after connect

Identifier validation (RejectsWhitespace/Empty* tests):
- Table names, primary keys, sort columns, and filter keys are sent
  unquoted on the wire; embedded whitespace breaks the protocol by
  splitting a single identifier into multiple tokens
- Add ValidateIdentifier helper and apply it to Search, Count, and Get

OFFSET-only emission (SearchWithOffsetOnlyAppliesOffset):
- offset > 0 with limit == 0 silently dropped the offset; callers
  expecting the server to skip N results got the first page instead
- Emit bare "OFFSET <n>" when only offset is non-zero

Mutex serialization (ConcurrentSendCommandsSerialize):
- The class header claimed thread-safety but Impl had no mutex; concurrent
  threads interleaved send/recv bytes and corrupted the protocol stream
- Add mutable std::mutex command_mutex_ to Impl; lock in SendCommand()

C API array validation (CApiSearchNullTermsCrashGuard):
- search_advanced / count_advanced with count > 0 but NULL array pointer
  dereferenced NULL and segfaulted; validate up front and return -1
- Move MygramClient construction inside the try block in mygramclient_create
  so a throw during construction is covered by unique_ptr and cannot leak

Error message improvements (ConnectInvalidHostnameIncludesGaiError,
SearchEmptyQueryReturnsError):
- DNS errors now include gai_strerror() text and the offending hostname
- EscapeQueryString emits explicit "" for empty strings so the server
  can parse a well-formed token instead of seeing a malformed command

C API replication status (CApiReplicationStatus):
- Expose GetReplicationStatus through the C API as
  mygramclient_replication_status / mygramclient_free_replication_status
- Add MygramReplicationStatus_C struct with running, gtid, status_str,
  processed_events, queue_size fields

Refactoring (no behaviour change):
- SendAndExpectPrefix helper consolidates the SendCommand -> error check
  -> prefix assert pattern used in every command method
- ParseColonKeyValueLines replaces ParseKeyValuePairs for colon-delimited
  multi-line responses; ParseKeyValuePairs is kept for "key=value" tokens
- CArrayToVector / CFilterArraysToVector / ForwardVoid helpers reduce
  copy-paste in mygramclient_c.cpp
- Protocol constants accessed via namespace alias (proto::) instead of
  inline constexpr copies
- Impl marked non-movable (std::mutex is not movable); MygramClient move
  semantics are preserved via its unique_ptr<Impl> member
…help

OPTIMIZE is a top-level command handled by the query parser; it has no
DEBUG subcommand form. The readline completion table offered "DEBUG
OPTIMIZE" as a valid sequence, misleading users into sending a malformed
command that the server would reject.

- Remove "OPTIMIZE" from the DEBUG subcommand completion list; only "ON"
  and "OFF" are valid DEBUG subcommands
- Add "OPTIMIZE [table]" to the inline help text so users can discover it
- Remove the "OK CONFIG" branch from the multi-line response display
  path: CONFIG SHOW emits "+OK\r\n..." (handled by the existing +OK
  branch) and the server never emits a literal "OK CONFIG" prefix; the
  dead branch added noise and misleading documentation
- Remove the now-defunct kOkInfoPrefixLength and kOkReplicationPrefixLength
  re-export aliases (constants removed from protocol_constants.h in the
  dead-code cleanup) and their corresponding prefix-length tests
FormatConfigResponse in ResponseFormatter was never called from any
handler; CONFIG SHOW is served by a separate code path that formats the
YAML directly. kOkInfoPrefixLen and kOkReplicationPrefixLen in
protocol_constants.h were only referenced by the now-deleted CLI
re-export aliases.

- Remove FormatConfigResponse declaration from response_formatter.h and
  its ~60-line implementation from response_formatter.cpp
- Remove kOkInfoPrefixLen and kOkReplicationPrefixLen from
  protocol_constants.h; the only caller (mygram-cli.cpp re-exports)
  was also removed in the preceding CLI commit
- Remove FormatConfigResponse and FormatConfigResponseUsesCRLFLineEndings
  unit tests from tests/server/response_formatter_test.cpp
StartSync held sync_mutex_ via lock_guard while calling join() on the
previous sync thread, but BuildSnapshotAsync's terminal update_state
lambda re-acquires the same mutex. If the prior thread was waiting on
update_state at that moment, the two paths deadlocked. This matched the
pattern that ~SyncOperationManager already documents and avoids.

Switch StartSync to unique_lock, move the previous thread out of
sync_threads_ under the lock, then unlock before join() and re-lock to
continue. Adds a regression test that re-runs StartSync after the prior
sync drained through the failure path, guarded by a 5-second timeout so
a future deadlock surfaces as a test failure instead of a hang.
The Init* helpers in ServerLifecycleManager wrapped make_unique calls in
try/catch and converted std::exception into Expected<...> errors. None
of the constructed objects throw, and catching std::bad_alloc only
hides OOM that should terminate the process. CLAUDE.md mandates
Expected<T,Error> instead of exceptions, so the wrappers were both
dead code and a regression in error fidelity. RequestDispatcher had the
same pattern around handler->Handle(); handlers return strings via
ResponseFormatter::FormatError and never throw. Both have been removed.

SnapshotScheduler's constructor previously logged an error and kept
going when catalog_ was null, leaving the object in a state that would
crash on the next TakeSnapshot. Move the precondition check into
ServerLifecycleManager::InitScheduler so a null catalog produces an
Expected error before construction, and document the constructor's
non-null contract.

Adds a regression test covering the InitScheduler optional path
(interval_sec == 0) and tightens the success path to verify IsRunning.
…race

reactor_handler_ (std::function) was assigned by the main thread via
TcpServer::Start->SetReactorHandler after the accept thread had already
been spawned by ServerLifecycleManager::InitAcceptor. The accept loop
read reactor_handler_ on every connection, producing a data race that
TSan flagged as UB.

Split Start() into two phases. Start() now only binds and listens.
StartAccepting() spawns the accept thread and is the new place where
TcpServer wires it up - after SetReactorHandler has run, so the
std::thread launch creates the happens-before edge that synchronizes
the handler write with the worker. StartAccepting requires both Start()
and a non-null handler and reports kNetworkAcceptorNoHandler (new error
6025) or kNetworkServerNotStarted otherwise. Stop() resets the thread
slot so Start->Stop->Start->StartAccepting is reusable.

Existing acceptor tests were updated to call StartAccepting after
SetReactorHandler. Five new cases cover the precondition matrix and
restart-after-stop.
ConnectionAcceptor::server_fd_ was a plain int read by the accept loop
and written by Stop, which is a data race that TSan flags as UB.
Promote it to std::atomic<int>. Stop() now exchanges the fd out before
shutdown/close so accept callers either see the live fd or -1, never an
in-flight transition. AcceptLoop snapshots the fd with acquire ordering
before each accept call so the loop terminates promptly when Stop runs.

KqueueMultiplexer::Add/Modify/Remove released interest_mutex_ between
reading the old interest mask, issuing the kevent syscall, and writing
the new mask back. Concurrent Modify calls on one fd could read the
same old mask, both issue diff-syscalls, then race to write the map -
the kernel ended up with one filter set while interest_ recorded
another, leaking EVFILT_WRITE filters and producing event storms.

Hold interest_mutex_ across the kevent syscall and the map update so
the kernel and map advance together. Add a fast-path early-return when
the new mask matches the old, and a regression test that hammers Modify
from 8 threads to verify no spurious failures or divergence.
IoReactor::Register dropped connections_mutex_ between inserting the
fd and arming the multiplexer, so a concurrent Stop() could clear
connections_ in that gap and leave the entry stranded. Restructure
Register to take mux_lifecycle_ (shared) first, then connections_mutex_
(unique), and run the running_ check, map insert, and mux_->Add inside
that single window with proper rollback on failure.

ReactorConnection::OnReadable made its close decision across two
separate critical sections - it sampled pending_frames_, dropped
frame_mutex_, then sampled write_queue_ under write_mutex_. A drain
task finishing between the two could push a response after the first
sample saw the queue empty, and the connection would close before the
response went out. Hold both mutexes (frame->write order) for the
empty-and-eof check so the close transition is atomic with the work
queues.

DrainTask cleared drain_scheduled_ before re-checking whether to
requeue, opening a window where another path could schedule a second
drain and break the at-most-one-task invariant. Keep drain_scheduled_
true while the task decides to reschedule; only clear it on the
no-more-work path inside frame_mutex_. The follow-up Submit happens
outside the lock and inherits the still-true flag.

IoReactor Start/Stop could race on the event_loop_thread_ slot: if
Stop ran between running_=true and the std::thread assignment, Stop
saw a non-joinable thread and returned, leaking the loop that Start
was about to spawn. Add start_stop_mutex_ and serialise both phases.
Documents the lock order start_stop_mutex_ -> mux_lifecycle_ ->
connections_mutex_.

New regression coverage: rapid and concurrent Start/Stop cycles for
IoReactor, Register racing Stop without stale entries, and a peer
half-close path that dispatches a frame and verifies the response
reaches the peer before the connection closes.
Start could spawn the scheduler thread after Stop had already flipped
running_ to false and missed the join, leaking the thread that
SchedulerLoop was about to enter. Add start_stop_mutex_ around the
running_ flip plus thread construction in Start and notify_all/join in
Stop. SchedulerLoop never takes the mutex, so the wait inside the loop
stays correct.

TakeSnapshot wrote the dump without pausing the binlog reader, so the
auto-snapshot path could observe Index/DocumentStore being mutated
concurrently and emit a torn dump - DumpHandler already guards against
this via replication_paused_for_dump, but the scheduler did not.
Mirror that guard: stop the reader, set the flag, capture GTID, and
let a ScopeGuard restart replication on every exit path. The
ReplicationHandler check that already rejects manual REPLICATION START
when the flag is set now applies to scheduled snapshots too.

Threads through the new replication_paused_for_dump reference via the
SnapshotScheduler constructor and ServerLifecycleManager::InitScheduler.
Adds a stub-based test that verifies the flag pulses around WriteDump
and a concurrent Start/Stop test that fails without the lifecycle
mutex.
HTTP /search and /count concatenated the URL-derived table name and
the JSON q field straight into the parser command. q could carry parser
clause keywords like "foo LIMIT 0 OFFSET 999999" that silently
overrode the JSON-supplied limit/offset, and a table name containing
whitespace or punctuation could split the parser tokens entirely.

Add IsValidTableName (alphanumerics, underscore, hyphen, dot, plus any
byte >= 0x80 to keep UTF-8 names working) and
ValidateQueryTextNoReservedClauses (rejects unquoted LIMIT/OFFSET/
ORDER/FILTER/SORT/HIGHLIGHT/FUZZY tokens, case-insensitive, while
still permitting them inside single- or double-quoted phrases and
keeping AND/OR/NOT as first-class search syntax). Both helpers run
before the string concatenation in HandleSearch and HandleCount and
return HTTP 400 with a message naming the offending input.

SearchHandler::HandleSearch had one path that returned
sorted_result.error().to_string() directly instead of going through
ResponseFormatter::FormatError, so a sort/pagination failure produced
"[Error 1004 ...] message" on the wire instead of the expected ERROR
prefix. Route it through FormatError like every other failure.

Adds smuggling regression tests for both endpoints, table-name
validation cases, and a TCP-side test that asserts the ERROR-prefixed
shape for sort failures.
Many StructuredLog().Event("server_error").Field("error", ...).Error()
sites lacked the numeric error_code, so log queries and Prometheus
alerts could not pivot on it. Add StructuredLog::FieldError(const
Error&) that emits message and error_code together as one chained
call. Apply it across dump/admin/io_reactor/sync_operation_manager
sites that were missing error_code, and promote reactor_poll_failed
from Warn to Error since epoll/kevent failures are not warnings.

CONFIG VERIFY failures were logging at Error level under server_error,
which is wrong for a client-input mistake. Rename the event to
config_verify_failed and downgrade to Warn so it stops triggering
operational alerts.

OK responses across handlers were a mix of "+OK", "+OK ...", and "OK
...", with each handler hand-rolling the literal. Add
ResponseFormatter::FormatOk(body) for the Redis-style status reply
("+OK [body]") and FormatStatus(body) for the result-style reply
("OK <body>"). Apply them in admin/cache/debug/dump/variable handlers
so the byte sequences on the wire are unchanged but the format lives
in one place.

Adds unit tests for both helpers.
Path validation lived in two places. dump_handler had a robust
canonical-path-plus-lexically_relative implementation; admin_handler's
HandleConfigVerify rolled its own with rfind('.') extension matching
and could be tricked through a directory-on-path symlink. Pull both
into mygram::utils::ResolveSafePath in src/utils/safe_path.{h,cpp},
exposing an Expected<string, Error> API with an optional allowed-
extensions filter, and call it from both handlers. dump_handler's
ResolveDumpPath stays as a thin wrapper to keep its callers unchanged.

The same SYNC-in-progress-block message was hand-built in
HandleDumpSave, HandleDumpLoad, REPLICATION_START, and DEBUG OPTIMIZE.
Add SyncOperationManager::CheckNoSyncInProgress(operation) that
returns Expected<void, Error> with the unified
"Cannot {operation} while SYNC is in progress for tables: a b c"
message, and call it from those four sites. variable_handler's
per-variable check stays as-is because its message format differs.

New tests cover the safe_path edge cases (.., absolute escapes,
extension allowlist, symlink-on-path) and the SYNC conflict path.
…earch prep

The "server is loading" guard was open-coded in three handlers
(search, document, facet) and the facet path duplicated it twice.
Add CommandHandler::CheckNotLoading() so each call site is one line
and the response stays byte-for-byte identical.

HttpServer split request counting between stats_ and tcp_stats_
asymmetrically: HandleInfo branched on tcp_stats_ != nullptr while
HandleSearch and HandleCount always wrote to stats_, so an operator
who supplied tcp_stats expecting unified counters got silently
inconsistent numbers. Add a single RecordRequest() helper and route
every handler entry point through it.

HandleSearch and HandleCount opened with ~100 lines of identical
preamble - loading check, table-name validation, table lookup, JSON
parse, q-field presence/type/control-char checks, reserved-keyword
filter, QueryParser, default limit, and JSON filters parse. Extract
PrepareHttpSearchQuery into HttpServer; both handlers now call it and
diverge only at the actual SEARCH/COUNT execution. Pagination handling
is gated by an apply_pagination flag so COUNT keeps skipping LIMIT/
OFFSET and the reserved-keyword error text still names only the
fields each endpoint accepts.

Adds a CommandHandler unit test for the loading-check contract and a
two-mode HttpServer test that locks in stats_ vs tcp_stats_ routing.
ExecuteSearchPipeline was a 290-line function with three near-
duplicated debug+timing blocks for the fuzzy, synonym, and regular
paths and inline orchestration of cache lookup, NOT/filter/verify, and
cache insertion. Split it into a 53-line orchestrator that delegates
to search_pipeline::ExecuteFullPipeline plus four small private
helpers (BuildPipelineParams, PopulateInputDebugInfo,
PopulateCacheHitDebugInfo, PopulateEmptyTermDebugInfo,
PopulatePostPipelineDebugInfo).

Routing both the HTTP HandleSearch path and the TCP SearchHandler
through ExecuteFullPipeline removes the duplicated orchestration glue
that previously diverged between the two protocols. To keep TCP debug
output byte-for-byte the same, surface the path tag, empty-term
detection, and cache age/saved metrics from FullPipelineOutput so the
TCP layer can recover the labels it used to compute itself. Cache-
lookup latency is now measured inside the pipeline and returned as
query_time_ms on hits, matching the previous TCP behavior. The pipeline
also skips cache insertion when empty_term_detected; HTTP gains this
conservative behavior alongside TCP and the rationale is documented in
the code.

Adds cross-protocol consistency tests that send the same SEARCH and
COUNT queries through both HTTP and TCP and assert the result counts
match.
Drop unused #include <spdlog/spdlog.h> from admin/debug/replication/
sync handlers and response_formatter, where logging now goes through
StructuredLog and spdlog is no longer referenced directly.

Mark the unused query parameter on SyncHandler::HandleSyncStatus so it
no longer trips the unused-parameter warning, matching the rest of the
codebase's no-name-on-unused-args convention.

Add a const overload to TableCatalog::GetTable so a const TableCatalog
can be queried; the non-const overload still returns a mutable
TableContext* for callers that need it. Adds a const-overload test.

GetTableContext returned kInternalError for a missing catalog and the
generic kNotFound (range 0-999) for a missing table. Per CLAUDE.md the
business-logic range is 4000-4999, so add kTableNotFound (4007) and
kCatalogNotInitialized (4008) and route the two error paths through
them. The wire message stays "Table not found: <name>" so client
behavior is unchanged.

ConnectionAcceptor's BUSY response used write() with the return value
silenced via NOLINT, which let SIGPIPE be raised when the peer had
already closed and gave us no signal that the notice was even
attempted. Switch to send(MSG_NOSIGNAL) on platforms that have it
(send(0) elsewhere - macOS already gets SO_NOSIGPIPE from
SetSocketOptions), keep the result intentionally unused with a comment
explaining why best-effort is correct here.

RateLimiter::AllowRequest's std::string parameter and ResolveDumpPath's
std::string args were considered but not converted: the former uses
std::string keys in an unordered_map without heterogeneous lookup, so
string_view would force a copy at the lookup site instead of saving
one; the latter is now a thin wrapper over ResolveSafePath which
already takes string_view.
- CacheManager::Clear/ClearTable now serialize via mutex_ so concurrent
  Insert can no longer leave phantom InvalidationManager metadata after
  QueryCache is emptied (P0-B).
- QueryCache::Clear now invokes eviction_callback_ for every entry,
  ensuring InvalidationManager state stays in sync when callers bypass
  CacheManager (P0-G).
- LookupInternal dedupes decompression_failures via the pending-keys set
  so multiple concurrent failed lookups of the same entry count once
  (P0-E).
- RemoveEntryLocked updates stats_.current_memory_bytes inline so
  GetStatistics reflects evictions immediately instead of after
  RefreshLRU (P0-H).
- CacheKey hash combiner switched from XOR to Fibonacci-mixed step,
  eliminating swapped-pair collisions.
- Disable() now drains the invalidation queue and clears all entries,
  giving a deterministic cold cache after Enable() rather than silently
  retaining stale entries.
- InvalidationQueue replaces hex-string composite keys with a typed
  PendingKey pair, removing per-event O(k) string allocations on the
  invalidation hot path.
- InvalidationManager::ClearTable is now O(k) via a table_to_cache_keys_
  reverse index instead of scanning all metadata.
- CacheStatistics gains rejection_count, forced_clears, max_memory_bytes,
  min_query_cost_ms, ttl_seconds, and compression_enabled for operator
  visibility into cache configuration and eviction patterns.
- LRUEviction test now asserts the strict-LRU victim is gone; new
  no-compression fixture covers the previously untested code path.
- DUMP LOAD now restores replication on every error path. Filepath
  validation runs before binlog stop so bad input fails fast, and a
  ScopeGuard ensures replication restart and flag clearance even on
  ReadDump failures. Previously a bad path or read failure left the
  server permanently in loading mode with replication stopped (P0-A).
- HttpServer::Start replaces check-then-set on running_ with
  compare_exchange_strong, eliminating the race where two concurrent
  Start calls could both spawn a server thread or where Stop could
  observe running_=true and skip the join (P0-C). Internal failure
  paths use store(false) only after holding the gate via successful
  CAS.
- SyncOperationManager::StartSync now uses a three-phase pattern
  (validate-and-claim → join-previous → spawn) with an explicit
  JOINING_PREVIOUS transitional status. The previous code dropped
  sync_mutex_ across the previous_thread.join(), exposing a window
  where concurrent racers could observe partially-modified state
  (P0-D). Crucially, the slot is now claimed unconditionally during
  Phase 1 — earlier code only claimed when a previous thread existed,
  letting a fresh burst of concurrent StartSync calls all reach the
  spawn step.
- AdminHandler null-catalog guard scoped to INFO so CONFIG HELP/SHOW/
  VERIFY remain usable without a catalog (matches existing test
  fixtures).
- SyncHandler/ServerLifecycleManager error codes moved to the correct
  ranges (Index 4xxx for kSyncManagerNull, Server 6xxx for
  kServerInitMissingDependency).
- Set TCP_NODELAY on accepted sockets (TCP only, not UDS) to remove
  Nagle delay on the request/response protocol.
- HTTP server enforces a configurable max body size via
  set_payload_max_length, returning 413 for oversize requests.
  api.http.max_body_bytes added to config.
- Reactor connections track last_active_ on every read/write event;
  IoReactor periodically reaps connections idle longer than
  idle_timeout_sec, complementing TCP keepalive whose defaults can be
  hours. Slow-loris-style stalls no longer hold connection slots
  indefinitely.
- TcpServer and HttpServer now share a single RateLimiter so a client
  cannot get 2x the configured quota by spreading load across protocols.
- IoReactor::Register adds the fd to the multiplexer first, then to
  connections_, removing the rollback path and the window where Lookup
  could return a fd not yet registered with the kernel poll set.
- ThreadPool shutdown with timeout uses a condition_variable instead
  of 10ms sleep polling, returning promptly when workers drain.
- RateLimiter cleanup sweep moved to a dedicated background thread
  with cv-based shutdown, eliminating O(n) latency spikes on the
  request hot path.
- Remove direct <spdlog/spdlog.h> includes from server source — all
  logging goes through StructuredLog. Documented at file top.
- Comment expansions: kqueue thread-safety contract, MSG_NOSIGNAL
  portability guard, RemoveConnection idempotency post-Stop,
  TokenBucket double-precision rationale.
- FacetHandler now routes through search_pipeline::ExecuteFullPipeline
  instead of calling Execute directly. Synonym expansion, fuzzy
  matching, and result caching now apply identically to facet-scoped
  searches and SearchHandler — previously the two diverged silently.
- Extract BuildPipelineParamsFromContext as a free function so HTTP
  HandleSearch/HandleCount and the TCP path build params identically.
- New HttpServer::ResolveHttpTableContext consolidates the
  IsValidTableName + table lookup + index/doc_store null-check pattern
  duplicated across HandleSearch, HandleCount, and HandleGet. HandleGet
  also now validates table names like the other handlers.
- RequestDispatcher drops the redundant TableExists pre-check since
  every handler that needs the catalog re-validates via GetTableContext.
- ResponseFormatter cache-debug section unified between SEARCH and
  COUNT via a shared WriteCacheDebugLines helper.
- ResolveSafePath gains a base_dir_label parameter so DumpHandler no
  longer needs a string-rewriting wrapper.
- Shared utilities: src/utils/roaring_bitmap_ptr.h provides
  RoaringBitmapPtr / MakeRoaringFromVector / MakeEmptyRoaring,
  replacing the local typedefs in search_pipeline and the manual
  bitmap construction in facet_handler.
- New src/server/operation_names.h centralizes operation strings
  passed to CheckNoSyncInProgress.
- DebugHandler::OPTIMIZE drops its local OptimizationGuard struct in
  favor of mygram::utils::AtomicFlagResetGuard. CACHE ENABLE/DISABLE
  and OPTIMIZE responses now route through ResponseFormatter::FormatStatus.
- ServerStats covers every QueryType — uncommon commands accumulate
  into cmd_other_, and RequestDispatcher::Dispatch now calls
  IncrementRequests so total_requests is non-zero (P0-I).
- SearchHandler emits a structured search_completed event with
  query_time_ms / cache_hit / result_count for slow-query analysis.
- DumpHandler emits dedicated dump_load_failed / dump_verify_failed
  event names instead of the generic server_error, and dump_save_started
  now includes the captured GTID.
- AdminHandler::CONFIG VERIFY adds an O_NOFOLLOW probe to narrow the
  TOCTOU window between symlink check and LoadConfig.
- TableCatalog: documented as immutable-post-construction; mutex
  removed since it served no purpose for the read-only access pattern.
- HttpServer adds GetEffectiveStats so callers always read the active
  counter source whether stats are local or shared with TcpServer.
- SyncStatus IDLE response now goes through ResponseFormatter for
  protocol framing consistency.
- Cross-cutting: structured_log.h promotes kMaxQueryLogLength to
  namespace scope; RequestDispatcher truncates the request field and
  emits a separate request_full_length so log injection / unbounded
  log volume from untrusted client input is bounded.
- spdlog direct include removed from snapshot_scheduler and
  request_dispatcher (server-source convention is StructuredLog only).
- Comment expansions on intentional patterns (facet double
  CheckNotLoading, replication flag-clear ordering, snapshot scheduler
  cv discipline, dump info filepath disclosure) to immunize against
  future review re-flagging.
- New tests: roaring_bitmap_ptr_test, facet_handler_test,
  cache_handler_test, admin_handler_test, plus extensions to
  search_pipeline / response_formatter / server_stats /
  request_dispatcher / variable_handler / sync_handler tests.
…imeout config

Batch of correctness fixes and dead-code removal identified in Phase 0 review:

Bug fixes:
- CR-1: Cast consumed offset to std::ptrdiff_t (was ssize_t) in
  ReactorConnection::ExtractFramesLocked to satisfy vector::iterator
  arithmetic requirements and prevent UB on the erase call.
- CR-8: Register SYNC_STOP in ServerLifecycleManager::InitDispatcher
  alongside SYNC and SYNC_STATUS; omission caused "Unknown query type"
  for every "SYNC STOP <table>" client command despite the handler
  logic being fully implemented in SyncHandler.
- H-N7: Remove RecordRequest() from all four /health/* handlers
  (HandleHealth, HandleHealthLive, HandleHealthReady, HandleHealthDetail)
  so orchestrator liveness/readiness probes do not inflate total_requests
  and distort application QPS metrics.

New feature:
- H-N8: Expose api.http.read_timeout_sec / write_timeout_sec as YAML-
  configurable fields. Added fields to ApiConfig::HttpConfig (config.h),
  ParseConfigFromJson (config.cpp), config-schema.json, and wired into
  HttpServerConfig::FromConfig. Non-positive values are ignored to keep
  the existing default (kHttpTimeoutSec = 5s).

Dead-code removal:
- H-N6: Delete TcpServer::StartSync, TcpServer::GetSyncStatus, and the
  accompanying friend class SyncHandler declaration; no caller remained
  after the SyncHandler refactor in the previous commit.
- H-N6: Remove atomic<bool> shutdown_requested_ from TcpServer; the
  Stop() path never read it and reactor connections use their own
  shutdown signaling.
- Drop unused constexpr kDefaultConnectionRecvTimeoutSec (tcp_server.cpp)
  and kSyncPollIntervalMs (sync_operation_manager.cpp).

Startup completeness check (CR-8):
- Add RequestDispatcher::HasHandler() query method.
- Add a compile-time-sized array of all required QueryTypes in
  InitDispatcher and iterate it after registration; any unregistered
  entry returns kServerInitMissingDependency (fail-fast) to prevent
  silent handler-table drift when new QueryType enum values are added.

Testing (6 new, 2 revised, all 2976 fast tests pass):
- HasHandlerReturnsTrueForRegisteredTypes: pins positive/negative cases
  for RequestDispatcher::HasHandler.
- Initialize_DispatcherRegistersAllSyncQueryTypes: regression test for
  SYNC/SYNC_STATUS/SYNC_STOP wiring (server_lifecycle_manager_test.cpp).
- HttpTimeoutsParsedFromYaml: verifies read_timeout_sec/write_timeout_sec
  round-trip through LoadConfig (config_test.cpp).
- FromConfigPropagatesHttpTimeouts / FromConfigIgnoresNonPositiveTimeouts:
  pin HttpServerConfig::FromConfig wiring (http_server_test.cpp).
- HealthChecksAreNotCountedInTotalRequests: replaces
  HealthChecksTrackedSeparately; asserts zero delta on total_requests
  after hitting all four /health/* endpoints (health_endpoint_test.cpp).
- Revise MultipleRequests in http_server_basic_test.cpp to use /info
  (counted) vs /health (not counted) for the request-counter assertion.
Lifecycle, dump, and replication:

- Replace load()+store(true) in HandleDumpSave and HandleDumpLoad with
  compare_exchange_strong to close the TOCTOU window where two concurrent
  clients could both observe false and spawn duplicate workers.
- Move dump_save_in_progress release into a ScopeGuard at the top of
  DumpSaveWorker so the flag becomes false strictly after Complete()/Fail()
  notifications; previously the explicit store(false) ran before Complete,
  leaving a window where a post-Complete observer saw the slot still busy.
- Restructure TcpServer::Stop() into four ordered phases:
  (1) set shutdown_in_progress_ flag, (2) join dump worker, stop sync
  manager, and stop snapshot scheduler before the network stack tears down,
  (3) stop reactor then acceptor, (4) drain thread pool last so in-flight
  close callbacks can complete cleanly.
- DumpSaveWorker checks shutdown_in_progress_ before calling
  binlog_reader_->Start() after a dump completes; skip restart when server
  is tearing down to avoid racing the binlog_reader destructor.
- Document the synchronous contract of IBinlogReader::Stop() in Doxygen --
  implementations must join their worker thread before returning; add new
  test binary binlog_reader_stop_contract_test verifying mock compliance.
- Add HandlerContext::shutdown_flag (std::atomic<bool>*) and
  TcpServer::shutdown_in_progress_ to wire the signal from Stop() to workers.

Multiplexer:

- Rewrite KqueueMultiplexer::ApplyInterest to issue one kevent() syscall
  per change record instead of batching both EVFILT_READ and EVFILT_WRITE
  in a single call; track applied_interest incrementally so interest_ stays
  in lockstep with the kernel on partial failures where the first filter
  succeeds and the second fails.
- Remove() also serialised per-filter to avoid the same partial-application
  hazard on teardown; ENOENT/EBADF are still silently tolerated for
  idempotent teardown.
- Clarify thread-safety contracts in EventMultiplexer, KqueueMultiplexer,
  and EpollMultiplexer headers: Add/Modify/Remove are safe for concurrent
  callers; Poll is event-loop thread only.

Cache:

- Set max_load_factor(0.5) and reserve() in QueryCache constructor to
  suppress rehash under the steady-state working set; add comment
  explaining why iterator stability supports the shared_lock contract in
  LookupInternal.
- Introduce QueryCache::EraseWithoutCallback; use it in
  InvalidationQueue::ProcessBatch and the immediate-erase path in Enqueue
  so UnregisterCacheEntry fires exactly once per affected key (via the
  queue), never via the eviction callback, closing the double-unregister
  race that could corrupt table_to_cache_keys_ / ngram_to_cache_keys_
  auxiliary indexes on concurrent re-registration.
- In CacheManager::Disable, store(false) on enabled_ before Clear() (was
  after); concurrent Insert observing the post-flip state now short-
  circuits at the enabled_ check and cannot re-populate the cache after
  Clear() completes; Clear body inlined with serialize_mutex_ to avoid the
  short-circuit in the public Clear() method.
- In CacheManager::Enable, start the invalidation queue before setting
  enabled_=true so that an Invalidate() arriving immediately after Enable
  finds the queue already running.
- InvalidationQueue::Start() resets stopped_=false before launching the
  worker thread so a Stop()/Start() cycle (Disable/Enable) does not leave
  stopped_ permanently set and silently drop all subsequent Enqueues.

Tests:

- New tests in tcp_server_lifecycle_test.cpp,
  binlog_reader_stop_contract_test.cpp, dump_handler_test.cpp,
  event_multiplexer_test.cpp, cache_manager_test, invalidation_queue_test,
  and query_cache_test cover the regressions above.
- All 2997 fast tests pass (ctest -LE "SLOW|LOAD" -j 4).
Network hardening:

- HttpServer::Start(): remove promise/future startup handshake and its
  join-deadlock window. bind_to_port is now called synchronously on the
  calling thread; the worker thread owns only the listen_after_bind accept
  loop. server_->wait_until_ready() ensures the accept loop is entered
  before Start() returns, so a racing Stop() cannot leak the thread.
- IoReactor wakeup: add EventMultiplexer::Wake() virtual method with a
  default no-op. EpollMultiplexer implements it via eventfd(2) registered
  on the epoll instance during Open(); KqueueMultiplexer implements it via
  EVFILT_USER sentinel (ident kWakeIdent=0xFFFFFFFE). IoReactor::Stop()
  calls Wake() before joining the event-loop thread, so Poll() returns
  immediately instead of waiting up to poll_timeout_ms. Poll() drains/drops
  the wake event from its output vector so callers never observe it as a
  ready fd.
- ConnectionAcceptor EMFILE/ENFILE backoff: replace
  std::this_thread::sleep_for with condition_variable::wait_for guarded by
  should_stop_. Stop() acquires stop_mutex_ to prevent the missed-notify
  race, then calls stop_cv_.notify_all(), so a sleeping accept loop returns
  in microseconds instead of up to 100 ms.
- RateLimiter::GetStats() lock scope: hold mutex_ only for the
  client_buckets_.size() snapshot; atomic counters are read outside the
  lock. Total is derived as allowed + blocked rather than loaded from
  total_requests_ directly to preserve the invariant across concurrent
  fetch_add pairs without a wider critical section.
- ReactorConnection write accounting: add underflow guard in
  DrainWriteQueueLocked around the write_queue_bytes_ decrement. Debug
  builds assert; release builds log the anomaly via structured log and
  clamp write_queue_bytes_ to sent_bytes before subtracting, keeping the
  connection live and the bug visible to operators.
- IoReactor::Register rollback logging: change (void)mux_->Remove(fd) to a
  checked call; failure is logged at Error severity with the fd and error
  fields so operators can correlate kernel poll-set inconsistencies.

Dump/sync shutdown correctness:

- replication_pause_counter.h (NEW): process-wide atomic reference counter
  for replication-pause requests. RequestPause() returns true only on the
  0->1 transition (first pauser calls binlog Stop()); ReleasePause()
  returns true only on the 1->0 transition (last releaser calls Start()).
  Header-only with a function-local static; includes ResetForTesting() for
  test fixtures. DumpSaveWorker, HandleDumpLoad, and SnapshotScheduler::
  TakeSnapshot all migrated to use RequestPause()/ReleasePause() so
  concurrent operations cannot double-Stop() or prematurely Start() the
  binlog reader.
- SyncOperationManager::StartSync() shutdown guard: check
  shutdown_requested_ (acquire) at the top of StartSync(). Returns
  kServerShuttingDown (new ErrorCode 6027) immediately, preventing a new
  SYNC from claiming the sync slot and spawning a worker that
  RequestShutdown would immediately cancel and race-join. RequestShutdown()
  updated to release-store shutdown_requested_ so the StartSync acquire
  fence sees it.
- ErrorCode::kServerShuttingDown (6027) added to error.h with string table
  entry "Server is shutting down".

Tests:

- Regression tests added for all six fixes: HttpServer bind-on-caller-
  thread, IoReactor wakeup promptness, ConnectionAcceptor EMFILE shutdown
  response, RateLimiter lock-minimization consistency, dump_handler /
  snapshot_scheduler counter coordination, and SyncOperationManager
  shutdown rejection.
- 3003/3004 tests pass; one failure (HttpServerStartupTest.
  StartOnOccupiedPortReturnsError) confirmed as a pre-existing port-
  collision flake in parallel ctest runs (three consecutive solo runs
  pass).
…ster

Implement cache correctness and performance improvements (Phase 3 HIGH):

Memory accounting (H-M1):
- Add kSharedPtrControlBlockOverhead (24 B) to CacheEntry::MemoryUsage()
  so every compressed entry's control block is counted against the budget;
  addresses ~5-10% RSS under-report seen in fleet memory profiles
- Add kHashMapNodeOverhead (32 B) constant in query_cache.cpp and apply it
  symmetrically in Insert, Erase, EraseWithoutCallback, and RemoveEntryLocked
  so total_memory_bytes_ tracks actual heap cost including map node headers
- Adjust LRUEviction test kCacheBytes (3500 -> 4096) to account for the
  ~56 B per-entry increase from the two new constants

Lock-order deadlock fix (H-M3):
- RemoveEntryLocked no longer calls eviction_callback_ inline; instead it
  appends the key to an out-parameter evicted_keys vector
- Insert, Erase, Clear, ClearTable, and RefreshLRU all collect evicted keys
  under mutex_ and call FireEvictionCallbacks() after releasing the lock,
  eliminating the QueryCache::mutex_ -> InvalidationManager::mutex_ ordering
  that conflicted with the reverse order taken by InvalidateAffectedEntries

Batch eviction callback (H-M7):
- Add BatchEvictionCallback type and SetBatchEvictionCallback() to QueryCache
- Add FireEvictionCallbacks() helper that prefers BatchEvictionCallback over
  the per-key fallback when a bulk path fires
- CacheManager wires SetBatchEvictionCallback to
  InvalidationManager::UnregisterCacheEntries so Clear/ClearTable/EvictForSpace/
  RefreshLRU take InvalidationManager::mutex_ exactly once per bulk operation
  instead of once per key

Batch unregister (H-M7):
- Add InvalidationManager::UnregisterCacheEntries(const vector<CacheKey>&)
  that acquires mutex_ once and loops UnregisterCacheEntryUnlocked; idempotent
  on missing and duplicate keys

filter_columns_changed O(k) scan (H-M2):
- Replace O(N) full walk of cache_metadata_ with O(k) lookup via
  table_to_cache_keys_ reverse index in InvalidationManager::InvalidateAffectedEntries

Stale LRU observability (H-M6):
- EvictForSpace now emits query_cache_stale_lru structured log and bumps
  CacheStatistics::stale_lru_entries atomic counter when a key is present in
  lru_list_ but missing from cache_map_
- Add stale_lru_entries field to CacheStatisticsSnapshot and copy in GetStatistics()

Timestamp double-set fix (M-15):
- Remove redundant metadata.created_at / last_accessed assignment in
  CacheManager::Insert; QueryCache::Insert is the sole authoritative setter

Schema-drift guard (M-16):
- Add kCacheStatsFieldVersion constant and kExpectedCacheStatisticsSnapshotSize
  static_assert in query_cache.h to force reviewers to mirror field changes
  across CacheStatistics, CacheStatisticsSnapshot, and GetStatistics()

Testing:
- EvictionCallbackFiresWithoutHoldingCacheLock: re-entrant Lookup from inside
  eviction callback must not deadlock (H-M3 regression)
- BatchEvictionCallbackInvokedOncePerBulkOp: Clear fires batch callback once
  with all keys; per-key callback suppressed (H-M7)
- BulkPathFallsBackToPerKeyCallbackWhenBatchUnset: backward-compat check (H-M7)
- EvictionCallbackCanCallGetMetadataWithoutDeadlock: GetMetadata from callback (H-M3)
- StaleLruCounterStartsAtZeroAndStaysZeroUnderNormalOps: counter baseline (H-M6)
- MemoryUsageIncludesSharedPtrControlBlockOverhead: control block accounting (H-M1)
- UnregisterCacheEntriesRemovesBatch: batch/empty/duplicate key handling (H-M7)
- FilterColumnsChangedRestrictedToTargetTable: O(k) scope correctness (H-M2)
- All 197/197 cache tests pass (including SLOW)
Normalize StructuredLog call sites across the server, cache, and utils
layers as part of the Phase 4A logging and error-handling unification:

New constant header:
- Add src/server/log_field_names.h with canonical field-name constants
  in namespace mygramdb::server::log_fields (kFieldFilepath, kFieldFd,
  kFieldClientIp, kFieldGtid, kFieldError, kFieldErrorCode, etc.)
- Normalizes path/filepath -> kFieldFilepath, client_fd/connection_fd
  -> kFieldFd (int64_t), remote_addr -> kFieldClientIp across call sites

Event name standardization (60+ sites):
- Replace generic server_error / server_warning catch-all events with
  dedicated <module>_<verb>_<outcome> event names in:
  connection_acceptor (accept_failed, socket_bind_failed, unix_socket_*),
  dump_handler (dump_save_failed, dump_load_starting, dump_verify_*),
  admin_handler (config_help_failed, config_show_failed/warning),
  debug_handler (optimize_rejected),
  snapshot_scheduler, sync_operation_manager (sync_failed,
  sync_replication_start_failed, wait_all_sync_complete_timeout),
  http_server, tcp_server (tcp_server_start_failed, rate_limit_exceeded),
  thread_pool (thread_pool_non_graceful_shutdown, worker_thread_exception),
  rate_limiter, network_utils (invalid_cidr_entry)

StructuredLog migration in cache layer:
- Replace spdlog::warn() calls in invalidation_queue.cpp with
  StructuredLog events cache_invalidation_queue_enqueue_after_stop and
  cache_invalidation_queue_overflow

Expected<void, Error> chain for IBinlogReader::Start():
- Replace bool return + GetLastError() pattern with Expected<void, Error>
  at 4 call sites in dump_handler.cpp (DUMP SAVE and DUMP LOAD restart
  paths) and replication_handler.cpp (REPLICATION START user request);
  errors now surface via FieldError() for consistent structured output

Testing:
- 3011/3012 tests pass (existing flake StartOnOccupiedPortReturnsError)
- No new lint warnings beyond pre-existing baseline
…t boilerplate

Nine nearly-identical setsockopt() blocks in connection_acceptor.cpp
(each with an inline errno snapshot and a StructuredLog WARN) are
replaced by calls to a new socket_utils::TrySetSockOpt helper. The
two fatal listening-socket sites (SO_REUSEADDR, SO_KEEPALIVE on the
listener) remain inline with a comment explaining the deliberate split:
they require ERROR-level logs and an early return false, which the
non-fatal helper is not designed for.

Key changes:
- New module src/server/socket_utils.{h,cpp} in mygramdb_server
  library; exposes a pointer+len overload and an int convenience overload
- connection_acceptor.cpp: -43 LOC; 9 per-client option sites converted
  (SO_KEEPALIVE, TCP_KEEPIDLE/INTVL/CNT, macOS TCP_KEEPALIVE,
  SO_NOSIGPIPE, SO_RCVBUF, SO_SNDBUF, TCP_NODELAY)
- Helper always includes the fd in the warning, a small operability
  improvement over the previous inline calls that omitted it
- New tests/server/socket_utils_test.cpp: 5 cases covering int overload
  success (with getsockopt verification), pointer overload success,
  closed fd, invalid fd, and unsupported option on a Unix socket;
  all 3017 fast-suite tests pass
Adds a move-only RAII helper around the process-wide replication-pause
counter (Phase 4 M-6) and refactors the three call sites in
snapshot_scheduler and dump_handler to use it. The wrapper guarantees
that an Acquire() is always paired with a counter decrement on scope
exit, eliminating the manual RequestPause + ScopeGuard{ReleasePause}
boilerplate and closing a leaked-counter risk in DUMP SAVE: that path
held the increment across the WriteDump call and only decremented
inline at the bottom of the worker, with no fallback if a future
refactor introduced an early return between the two points.

Key changes:
- New Scope class in replication_pause_counter.h: Acquire() returns the
  first_pauser bool, Release() returns the last_releaser bool, dtor
  drops a held-but-unreleased counter as a safety net (does NOT call
  binlog Start; the explicit-release path owns that side-effect)
- Move-only ownership; double-Acquire / double-Release / Release-without-
  Acquire are no-ops to defend against scope-misuse bugs
- snapshot_scheduler.cpp: scope replaces inline Request/ScopeGuard pair
- dump_handler.cpp DUMP SAVE: scope guards the pause across WriteDump
- dump_handler.cpp DUMP LOAD: scope guards both the success path and
  the existing fallback ScopeGuard, which now Release()s the scope
  cooperatively (the success branch's explicit Release marks the scope
  spent so the fallback lambda's Release is a harmless no-op)
- New tests/server/replication_pause_counter_test.cpp: 10 cases covering
  Acquire/Release ordering, dtor safety net, double-Acquire/Release
  no-ops, move-construction ownership transfer, and the multi-scope
  first/last-pauser pattern; full fast suite stays at 100% (3027/3027)
libraz added 12 commits May 6, 2026 19:12
Adds a generic background-thread helper that runs a callback at a fixed
interval until Stop() is called, and migrates two of MygramDB's hand-
rolled std::condition_variable + std::thread + atomic<bool> trios onto
it (Phase 4 M-8). Both migrated sites previously carried the same
boilerplate inline: mutex_+cv_+stop flag, wait_for(predicate=stop),
unlock-around-task, lock again, repeat. The shared helper:

- centralises the "stop is fast" guarantee — Stop() notifies the cv so
  a worker sleeping on a long interval wakes within microseconds rather
  than waiting the full interval before the join() returns,
- catches exceptions thrown by the callback and logs a structured warn
  (event=periodic_worker_task_failed) instead of letting them fall out
  of the worker thread and call std::terminate, and
- emits start/stop/failure events under uniform names tagged with the
  worker's name so operators can correlate across modules.

Migrated sites:
- src/server/rate_limiter.cpp (SweeperLoop -> PeriodicWorker)
- src/cache/query_cache.cpp (RefreshLRUWorker -> PeriodicWorker)

Deferred sites and rationale (intentionally not part of this commit):
- src/server/snapshot_scheduler.cpp: has additional outer state
  (next_save_time) and a Stop-ordering contract with start_stop_mutex_;
  retrofitting needs more invasive changes than the value delivered.
- src/cache/invalidation_queue.cpp: not a fixed-interval periodic — it
  is a delay-aware queue drainer whose wait duration depends on queue
  contents. PeriodicWorker would be a poor fit.

Tests:
- New tests/utils/periodic_worker_test.cpp: 9 cases covering invalid
  inputs (empty task, zero interval, double-start), idle-Stop no-op,
  destructor stops a running worker, multi-tick firing, fast-Stop with
  long interval (<500ms vs 5s), throwing-callback resilience, and
  restart-after-stop. Marked SLOW (sleep-based timing).
- Existing rate_limiter / query_cache fast + SLOW suites pass
  unchanged: 3027/3027 fast + 33/33 SLOW (rate_limiter + periodic).
…ease

Phase 4 H-D1 unifies the four call sites that previously open-coded the
"compare_exchange_strong(false, true) then bind an AtomicFlagResetGuard"
pattern. The new utils::OperationGuard::TryAcquire() factory bundles
both steps so that:

- the test-and-set and the release happen as one logical operation,
  closing the well-known TOCTOU window where someone splits the
  compare_exchange and the guard binding (the exact bug class CR-2
  fixed in DUMP LOAD), and
- the new sites read identically across handlers, so a future reviewer
  can spot a missing "in-progress" guard at a glance.

Two release semantics are exposed:
- Release(): clear the flag and disengage. Use when this scope is the
  sole owner (DUMP LOAD success, OPTIMIZE, automatic snapshot).
- Dismiss(): disengage WITHOUT clearing. Use when ownership transfers
  to a different release path — specifically, the DUMP SAVE async path
  hands the flag to the worker thread, whose own RAII reset clears it
  at the end. A unit test pins this contract; the initial roll-out of
  this commit collapsed both into Release() and broke the DUMP SAVE
  concurrency test (two threads slipped through), which is why the
  Dismiss() distinction is now load-bearing.

Migrated sites:
- src/server/handlers/dump_handler.cpp HandleDumpSave (uses Dismiss())
- src/server/handlers/dump_handler.cpp HandleDumpLoad (uses Release())
- src/server/handlers/debug_handler.cpp OPTIMIZE
- src/server/snapshot_scheduler.cpp TakeSnapshot

Tests: tests/utils/flag_guard_test.cpp grows by 11 OperationGuard cases
covering engage/disengage paths, scope-exit release, explicit Release
+ Dismiss semantics, double-release safety, move-construction
ownership transfer, and the disengaged-default-construction case.
Full fast suite: 3038/3038 (was 3027/3027 before this batch).
…icWorker

Both fixes close windows where a Stop()-side thread could observe or
race against state written by a worker-side thread without holding the
same lock.

DumpProgress::StartWorker (server_types.h, dump_handler.cpp):
- HandleDumpSave previously wrote worker_thread directly from the
  handler thread with no lock held. JoinWorker() (called by
  TcpServer::Stop() and ~DumpProgress) reads worker_thread under
  DumpProgress::mutex. A Stop() arriving concurrently with the
  HandleDumpSave assignment was therefore a data race on the unique_ptr
  by the C++ memory model.
- New StartWorker(std::function<void()>) centralises the assignment
  inside DumpProgress::mutex. An assert guards the pre-condition that
  any prior worker has already been drained via JoinWorker(), making
  misuse loud rather than silently leaking a thread.

PeriodicWorker post-unlock recheck (periodic_worker.cpp):
- Loop() released mutex_ before calling task_(). A Stop() arriving in
  those few instructions would set should_stop_ and notify, but the
  worker had already committed to invoking task_() once more — one
  extra execution after shutdown was logically requested.
- Added a should_stop_.load(acquire) check immediately after the
  unlock. Stop() cannot have set should_stop_ while the worker held the
  mutex, so the only race window is between the unlock and this load.
  The break is safe: unique_lock tracks ownership state, so the
  destructor will not double-unlock.

Regression test (periodic_worker_test.cpp):
- 200-iteration stress loop: sets a stop_requested flag immediately
  before Stop(), and asserts that no callback invocation observes the
  flag. With the original code, callbacks occasionally raced past the
  in-lock check and then fired inside the now-unlocked window; the
  recheck prevents that.
Four files had line-length and indentation violations detected by the
CI clang-format check introduced after commit af39bea. No functional
or logical changes.

Affected files:
- src/server/snapshot_scheduler.cpp: reformat ScopeGuard lambda
  capture and StructuredLog chained calls to fit within column limit
- src/utils/periodic_worker.cpp: reformat StructuredLog chains in
  Start() validation paths; collapse a short chain in Loop() that was
  already under the limit back to a single line
- tests/server/socket_utils_test.cpp: join split TrySetSockOpt call
  onto one line
- tests/utils/periodic_worker_test.cpp: reflow two ASSERT_TRUE(worker
  .Start(...)) call sites to fit the column limit
Extend the JSON search endpoint with three new query options and harden
the build system for macOS and cleaner module boundaries.

HTTP search enhancements (src/server/http_server.cpp):
- ParseSortFromJson: accept {"sort": {"column": "...", "order": "ASC|DESC"}}
  with safe column-name validation (IsSafeJsonColumnName, EqualsAsciiIgnoreCase)
- ParseFuzzyFromJson: accept {"fuzzy": 1|2} for edit-distance search
- ParseHighlightFromJson: accept {"highlight": {"open_tag", "close_tag",
  "snippet_length", "max_fragments"}} with range validation
- BuildHighlightTerms: expand query terms through synonym dictionary and
  normalize before passing to query::Highlighter::Generate
- HandleSearch: attach per-document "highlight" snippet to JSON results;
  returns HTTP 400 when highlight is requested but text storage is disabled
- Fix filters type guard: previously accepted non-object values silently;
  now returns HTTP 400 with "Field 'filters' must be an object"

Startup transactional safety (src/app/server_orchestrator.cpp,
src/server/tcp_server.cpp):
- ServerOrchestrator::Start is now fully transactional: HTTP server failure
  stops both TcpServer and BinlogReader, leaving no background workers running
- TcpServer::Start stops reactor and acceptor on their own failure paths
- shutdown_in_progress_ is reset to false at the top of Start so restarts
  after a previous Stop do not see a stale flag

Build system (CMakeLists.txt, Makefile, src/CMakeLists.txt,
src/config/CMakeLists.txt, src/server/CMakeLists.txt):
- Extract RuntimeVariableManager into a dedicated mygramdb_runtime_config
  static library to break the circular dependency between mygramdb_config
  and mygramdb_cache; consumers updated accordingly
- Remove duplicate top-level MySQL detection block; consolidate under
  USE_MYSQL guard with mysql-client@8.4 preferred, fallback to mysql-client
- Change missing-MySQL from WARNING to FATAL_ERROR with -DUSE_MYSQL=OFF hint
- Makefile: replace $$(nproc) with portable NPROC variable (falls back to
  sysctl -n hw.ncpu on macOS, then 4)
- Remove redundant mygramdb_server link from mygramdb executable

CMakeLists.txt cleanup across tests:
- runtime_variable_manager_test and runtime_variable_manager_mysql_test
  link mygramdb_runtime_config instead of mygramdb_config + mygramdb_cache
- Remove redundant transitive link targets from tests/app, tests/index,
  tests/loader, tests/server test executables

Tests (tests/server/http_server_search_test.cpp):
- SearchSupportsJsonSort: verifies ASC sort on a filter column
- SearchSupportsJsonFuzzy: verifies edit-distance=1 matches "machime" -> "machine"
- SearchSupportsJsonHighlight: verifies <strong> tags appear in snippet output
- SearchRejectsInvalidJsonFiltersType: verifies HTTP 400 for array-typed filters
…check

Follow-up to the JSON search API additions. Two independent robustness
fixes in ParseHighlightFromJson and IsSafeJsonColumnName.

Highlight tag length cap (src/server/http_server.cpp):
- Add kMaxHighlightTagLength = 256 constant
- Reject open_tag / close_tag values longer than 256 bytes with HTTP 400
  and a clear error message; prevents response-size amplification that
  could be triggered by a crafted request with a very long tag string

Column-name predicate cleanup (IsSafeJsonColumnName):
- Replace c == '$' with u == '$' (unsigned char) for consistency with
  surrounding comparisons and to avoid any signed-char UB hazard
- Remove redundant std::isspace / std::iscntrl guards; all characters
  they would reject are already excluded by the ascii_safe whitelist

Test (tests/server/http_server_search_test.cpp):
- SearchRejectsOversizedHighlightTags: sends a 257-byte open_tag, expects
  HTTP 400 with an error mentioning "open_tag" and "at most 256 bytes"
- MYSQL_PORT: 13306 -> 23306 in conftest.py and docker-compose.yml
- MYGRAMDB_HTTP_PORT: 18080 -> 20080 in conftest.py
- MySQL and HTTP port updated in mygramdb-test.yaml to match

Affected files:
- e2e/conftest.py
- e2e/docker/docker-compose.yml
- e2e/docker/mygramdb-test.yaml
Add CHANGELOG section, release index entry, and full release notes for
v1.6.1, a maintenance release covering lifecycle and concurrency
hardening, HTTP search API extensions (sort/fuzzy/highlight), and a
mygram-cli rewrite on top of MygramClient.
bm25_scorer.cpp lives in mygramdb_index and calls
DocumentStore::GetNormalizedTextBatch from mygramdb_storage. GCC's
left-to-right static-library scan resolved storage symbols before index
was processed, leaving the cross-reference unresolvable and producing an
undefined reference to the cxx11-ABI symbol on Linux CI. Listing
mygramdb_index explicitly before mygramdb_storage restores the working
link order; macOS clang tolerates the omission and now emits only a
harmless duplicate-library warning.
binlog_event_processor.cpp directly calls cache::CacheManager::Invalidate
and ClearTable, but mygramdb_mysql had no explicit link dependency on
mygramdb_cache. GCC's left-to-right static-library scan resolved
mygramdb_cache (pulled in transitively via mygramdb_server PUBLIC link)
before reaching mygramdb_mysql, leaving binlog_event_processor.o's
cache-symbol references unresolvable and causing binlog_reader_core_test
and binlog_reader_events_test to fail on Linux CI with undefined reference
errors. Adding the explicit dependency lets CMake order the libraries
correctly; macOS clang had accepted the omission due to its more permissive
symbol resolution.
…terministic

Replace the 50µs sleep in ConcurrentPauseReleaseHasExactlyOneFirstAndLast
with an atomic barrier that holds all 32 threads at the pause→release
boundary until every thread has called RequestPause(), guaranteeing the
counter traces 0→N→0 and eliminating false "multiple first/last" failures
on coverage and sanitizer runners. Replace the hardcoded port 18091 in
StartOnOccupiedPortReturnsError with port 0 so the OS assigns a free
ephemeral port, then read it back via getsockname() to prevent bind()
collisions with other tests running in parallel ctest sessions.
The previous assertion of exactly one success per round fails on
coverage/sanitizer runners where the ~10ms in-memory dump completes
before slow racing threads reach the compare_exchange, producing
legitimate sequential 0->1->0->1->0 transitions and multiple successes.
The test now verifies the true atomic invariant — all threads see a
valid response, at least one wins per round, and the success count stays
within bounds — and adds a Doxygen comment explaining why the stricter
bound cannot be asserted without a worker-completion hook in the fixture.
@libraz
libraz merged commit 7311be0 into main May 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants