diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md new file mode 100644 index 00000000..fce0f81c --- /dev/null +++ b/.claude/skills/review-pr/SKILL.md @@ -0,0 +1,354 @@ +--- +name: review-pr +description: Review a GitHub pull request against QuestDB client library coding standards +argument-hint: [PR number or URL] [--level=0..3] +allowed-tools: Bash(gh *), Read, Grep, Glob, Agent +--- + +Review the pull request `$ARGUMENTS`. + +## Review mindset + +You are a senior QuestDB engineer performing a blocking code review. The QuestDB client library is mission-critical software — bugs can cause data loss, silent data corruption, or crashes in customer applications across Rust, C, C++, Python, and downstream language bindings. There is zero tolerance for correctness issues, resource leaks, undefined behavior, or unsound FFI. Be critical, thorough, and opinionated. Your job is to catch problems before they ship, not to be nice. + +- **Assume nothing is correct until you've verified it.** Read surrounding code to understand context — don't just look at the diff in isolation. +- **The diff is a hint, not the boundary of the review.** The highest-value bugs almost always live at callsites outside the diff that depend on contracts the diff quietly changed. Treat the diff as the entry point, not the scope. +- **Flag every issue you find**, no matter how small. Do not soften language or hedge. Say "this is wrong" not "this might be an issue". +- **Do not praise the code.** Skip "looks good", "nice work", "clever approach". Focus entirely on problems and risks. +- **Think adversarially.** For each change, work through: + - Inputs: which values break this? Consider empty buffers, zero-length strings, boundary integers, max-length symbols. + - Encoding: how does the code behave when a string contains invalid UTF-8, embedded NUL bytes, or oversized lengths? + - Concurrency: what happens under concurrent access or interleaved calls from a host language? + - Failure modes: connection dropping mid-flush, partial write, TLS handshake failure, auth rejection. + - FFI callers: what happens when the caller passes NULL, an unaligned pointer, a freed handle, a buffer length lying about its actual size? +- **Check what's missing**, not just what's there. Missing tests, missing error handling, missing edge cases, missing documentation for public API changes, C header out of sync with Rust impl. +- **Verify every claim.** If the PR title says "fix", verify the bug actually existed and the fix is correct. If it says "improve performance", look for benchmarks or reason about the algorithmic change. If it says "simplify", verify the new code is actually simpler and doesn't drop behavior. Treat the PR description as an unverified hypothesis. +- **Read the full context of changed files** when the diff alone is ambiguous. Use Read/Grep/Glob to inspect surrounding code, callers, and related tests. +- **Assess reachability before reporting.** For every potential bug, trace the actual callers and inputs. If a problem requires physically impossible conditions (buffer larger than `usize::MAX`, NUL-byte injection through an API that already rejects it, panics behind validation guards), it is not a real finding — drop it. Focus on bugs that real workloads can trigger, not theoretical edge cases. +- **`debug_assert!` and `assert!` are valid guards for invariants** that indicate library-internal bugs. Do NOT flag them as insufficient — they are the preferred mechanism for conditions that should never occur given the FFI contracts. Only flag an `assert!`/`debug_assert!` if the condition can plausibly be triggered by callers honoring the documented contract. + +## Review level + +Parse `$ARGUMENTS` for a level token: `--level=N`, `-lN`, or a bare single digit `0`-`3`. **If no level is given, default to 0.** Strip the level token before feeding the remainder (PR number or URL) to `gh` commands. + +The level controls how much of the review below actually runs. Lower levels keep the same review *spirit* — adversarial, blocking, no praise — but cut the breadth of the analysis. Higher levels have significantly higher token cost; reserve level 3 for high-stakes PRs (FFI ABI changes, ILP wire format, authentication/TLS, public C/C++ headers, new `unsafe` blocks, sender or buffer state-machine changes). + +| Level | What runs | + +|-------|-----------| +| **0 (default)** | Steps 1, 2, 4. Skip Step 2.5. Skip Step 3 — no agent spawn; review the diff inline in the main loop, using Read/Grep on demand to resolve ambiguities. Skip Step 3b — verify each finding inline as you write it. Single-pass review covering correctness, FFI safety, panics, tests, and coding standards on the diff itself. |Expand commentComment on line R38Resolved +| **1** | Adds Step 2.5a (semantic delta only — skip 2.5b/2.5c/2.5d). In Step 3, launch only Agent 1 (correctness), Agent 2 (Rust safety), and Agent 7 (tests) in parallel. Skip all other agents. Skip Step 3b — verify findings inline as you draft the report. | +| **2** | Full Step 2.5, but in 2.5b restrict the callsite inventory to `pub`/`pub(crate)` Rust symbols plus every `#[no_mangle]`/`extern "C"` export. In Step 3, launch Agents 1-8. Skip Agent 9 (cross-context) and Agent 10 (adversarial fresh-context). Step 3b uses a single batched verification agent for all findings instead of one per finding. | +| **3** | Every step below as written, all 10 agents, per-finding verification. The full mission-critical pass. | + +State the chosen level in one line at the start of the review so the user knows what they're getting (e.g., "Reviewing PR #141 at level 2"). If the level was defaulted, mention that level 3 exists for full review. + +## Step 1: Gather PR context + +Capture the PR identifier in `$PR` (the part of `$ARGUMENTS` left after stripping the level token), then fetch metadata, diff, and review comments in a single bash call so `$PR` is in scope for all three `gh` invocations: + +```bash +PR='' +gh pr view "$PR" --json number,title,body,labels,state +gh pr diff "$PR" +gh pr view "$PR" --comments +``` + +## Step 2: PR title and description + + +Check: +- Title is clear and describes the change +- Description speaks to end-user impact, not implementation internals +- If fixing an issue, `Fixes #NNN` or a link to the issue is present +- Tone is level-headed and analytical +- For public API changes (C header, C++ wrapper, Rust public types), the description calls out the API change explicitly + +## Step 2.5: Map the change surface + +Before launching review agents, produce a structured change surface map. This step is mandatory and must use Grep/Glob — do not reason about callsites from memory. The output of this step is required input for every agent in Step 3. + +### 2.5a Semantic delta per changed symbol + +For every modified or added function, method, trait, struct field, public constant, or FFI export, write: + +- **Symbol:** fully-qualified name (e.g., `questdb::ingress::Sender::flush`, `line_sender_buffer_column_f64`) +- **Before:** signature, return type, error behavior, panic behavior, mutation (`&self` vs `&mut self`), ordering/idempotency guarantees, allocation behavior, thread-safety, FFI ownership semantics (caller-owned vs callee-owned) +- **After:** same fields +- **Delta:** one line stating what semantically changed + +"Refactored", "cleaned up", "improved", "simplified" are not acceptable deltas. State the actual behavioral difference. If nothing semantically changed, write "no behavioral change" — but only after checking, not as a default. + +### 2.5b Callsite inventory + +For every changed symbol that is `pub`, `pub(crate)`, `#[no_mangle]`, `extern "C"`, exported in the C header, or referenced from the C++ wrapper, run Grep across the entire repository to find every callsite, implementation, override, or reference outside the diff. + +Produce a list grouped by file. Search at minimum: + +- **Rust impls/callers:** `grep -r 'symbol_name' questdb-rs/ questdb-rs-ffi/` +- **Trait implementations:** `grep -rn 'impl.*TraitName' questdb-rs/` +- **C header references:** `grep -rn 'symbol_name' include/questdb/ingress/` +- **C++ wrapper inline functions / templates:** `grep -rn 'symbol_name' include/questdb/ingress/*.hpp` +- **C++ tests:** `grep -rn 'symbol_name' cpp_test/` +- **Python ctypes bindings and system tests:** `grep -rn 'symbol_name' system_test/` +- **Examples:** `grep -rn 'symbol_name' examples/` +- **Doc-tests and rendered docs inside the crate:** `grep -rn 'symbol_name' questdb-rs/src/` + +A changed `pub`/`pub(crate)`/`#[no_mangle]` symbol with zero recorded Grep calls in the trace is a skill violation. The model is not allowed to assert "this is only used here" without showing the search.Expand commentComment on line R95Resolved + +### 2.5c Implicit contract list + +For each changed symbol, walk this checklist and write one line per item, stating before vs after: + +- Panics on which inputs (and whether the panic crosses an FFI boundary) +- `Result::Err` variants returned and which `?` chains propagate them +- Iteration / flush ordering, idempotency, replay safety +- Re-entrancy and reentrant-call behavior (e.g., calling flush from inside a callback) +- Lock acquisition order and which locks are held on return; tokio runtime/blocking expectations +- Allocation on hot path (buffer build, column write, flush, encode) vs setup path (construction, configuration) +- `Send` / `Sync` bounds and whether the value can cross threads +- Whether the FFI ownership contract changed (caller-frees vs callee-frees, who owns returned pointers, lifetime relative to the parent handle) +- Buffer state on error: does a failed call leave the buffer half-written? Does the sender need re-construction after error? +- C header signature: parameter types, `const`-ness, length parameters, NUL-termination expectations +- Wire format: any change to the ILP bytes produced or accepted by the protocol parser + +### 2.5d Cross-context exposure list + +End this step with an explicit list of "places this change is visible from but the diff does not touch". This is the highest-priority input for the bug-hunting agents in Step 3. + +Group the callsites from 2.5b by execution context. Typical contexts in this codebase: + +- **FFI entry surface:** every `extern "C"` function in `questdb-rs-ffi/src/lib.rs` that calls (transitively) into the changed code +- **Buffer build hot path:** `Buffer::column_*`, `Buffer::symbol`, `Buffer::at*` and their callers +- **Flush path:** `Sender::flush*`, HTTP/TCP/ILP transports +- **Auto-flush logic:** any callsite that triggers flush implicitly +- **Configuration parsing:** `SenderBuilder`, conf string parsers in `questdb-rs/src/ingress/conf.rs` +- **Authentication / TLS:** TLS handshake in `questdb-rs/src/ingress/tls.rs`, basic auth, token auth +- **Async runtime:** code that runs inside the tokio runtime started by the client +- **C++ wrapper:** template instantiations, inline forwarders, RAII destructors in `include/questdb/ingress/*.hpp` +- **C++ doctest suite:** `cpp_test/test_line_sender.cpp` +- **Python system tests:** `system_test/test.py`, `system_test/fixture.py`, ctypes shims in `system_test/questdb_line_sender.py` +- **Documentation examples:** `examples/` programs that link the C/C++ API + +Every entry on this list must be reviewed in Step 3. + +## Step 3: Parallel review + +Every agent receives: +1. The PR diff +2. The full change surface map from Step 2.5 (semantic deltas, callsite inventory, implicit contracts, cross-context exposure list) + Expand commentComment on lines R137 to R138Resolved +### Anti-anchoring directive (applies to all agents) + +- **Bugs at callsites outside the diff outrank bugs inside the diff.** A confirmed bug in a file the PR did not touch but that calls a changed symbol is a P0 finding. +- **"Looks correct in isolation" is not a valid conclusion.** Before clearing a changed symbol, the agent must walk the callsite inventory from 2.5b and explicitly state, per callsite, whether the new behavior is still correct there. +- **The diff is the entry point, not the scope.** If the change surface map shows the symbol is reachable from N other files, the review covers N+1 files. +- A single finding of the form "in `test_line_sender.cpp` the new behavior of `line_sender_buffer_column_f64` causes Y" is worth more than five findings inside the diff. + +### Agents + +Launch the following agents in parallel. + +**Agent 1 — Correctness & bugs:** NULL/None handling, edge cases, logic errors, off-by-one, operator precedence, error paths, integer overflow/truncation, buffer length math. Cross-reference every changed symbol against its callsite inventory and verify the new behavior is correct at each callsite. + +**Agent 2 — Rust safety, panics, and crash surface:** In a client library, anything that aborts the Rust side aborts the host process with no recovery. Flag every reachable instance of: + +- **Panic sources:** `unwrap()`, `expect()`, array indexing without bounds checks, `panic!()`, `unreachable!()`, `todo!()`, integer overflow in release mode, `slice::from_raw_parts` with invalid inputs, `Mutex::lock().unwrap()` on a poisoned mutex. +- **Direct aborts:** `std::process::abort()`, `libc::abort()`, `std::intrinsics::abort()`. +- **Allocation-failure aborts:** any `Vec::with_capacity`, `Box::new`, `String::reserve`, or similar sized by an untrusted length parameter. Rust's default allocator aborts on OOM — a caller passing a 100 GB length parameter is a crash, not a recoverable error. Check whether the FFI function validates length bounds before allocating. +- **Stack overflow:** unbounded recursion, recursive `Drop` impls, deeply nested data structures decoded from untrusted input. +- **Panic-in-Drop / double-panic:** a panic inside a `Drop` impl while another panic is unwinding aborts the process. Flag any `Drop` impl that can panic (calls `unwrap`, indexes, allocates). +- **Unsound `unsafe`:** verify safety invariants are documented and upheld, check pointer validity, aliasing rules, lifetime correctness, dangling pointers, use-after-free, double-free, data races. +- **C++ exceptions escaping into C:** the C++ wrapper (`include/questdb/ingress/*.hpp`) is reachable from pure-C callers via inline forwarders. Any path where the wrapper can throw (`std::bad_alloc`, `std::system_error`, user-defined `throw`) and reach a C caller is undefined behavior. Verify wrapper functions called from C are `noexcept` or only invoked from C++ contexts. +- **SIGPIPE on broken sockets:** writing to a closed peer raises SIGPIPE by default on Linux/macOS, killing the process. Verify TCP/HTTP write paths set `MSG_NOSIGNAL` or mask SIGPIPE. + +Every fallible operation must use `Result`/`Option` with proper error propagation. Every `extern "C"` function must wrap its body in `catch_unwind` or prove no panic path exists. + +**Agent 3 — FFI boundary safety:** Check every `#[no_mangle]` / `extern "C"` function. Verify: NULL pointer checks on all pointer arguments, proper error propagation across the FFI boundary (no panics escaping into C), correct ownership transfer semantics (who allocates, who frees), buffer length validation, string encoding correctness (UTF-8 ↔ C strings, NUL handling), and that the C header (`include/questdb/ingress/line_sender.h`) and C++ wrapper (`include/questdb/ingress/line_sender.hpp` + the split `line_sender_core.hpp` / `line_sender_array.hpp` / `line_sender_decimal.hpp`) accurately reflect the Rust implementation. If `cbindgen.toml` is involved, verify generated output matches handwritten headers. + +**Agent 4 — Concurrency & thread safety:** Race conditions, `Send`/`Sync` bounds, shared mutable state, lock ordering, correct use of `Arc`/`Mutex`/`RwLock`, tokio runtime safety (blocking vs async calls), thread-safety of data structures. For C/C++ API: verify documented thread-safety guarantees match the implementation. Cross-reference every callsite from 2.5b for violations of the new concurrency contract. + +**Agent 5 — Resource management & memory:** Leaks on all code paths (especially errors), `Drop` implementations, native memory management, buffer lifecycle, socket/connection cleanup on error paths, TLS session teardown. For C API: verify every allocation has a documented deallocation path, and error paths don't leak. Walk every callsite from 2.5b that constructs, owns, or transfers ownership of changed types and verify cleanup on all paths (Ok branch, `?` early return, panic-unwind boundary). + +**Agent 6 — Performance & allocations:** Unnecessary allocations on hot paths (buffer building, column writes, flushing), excessive copying, inefficient serialization, unnecessary syscalls, buffer growth strategy. For each new loop on the data path, analyze how it scales with realistic message volumes (millions of rows per flush, hundreds of columns). Flag any O(n²) pattern. Setup-path allocations (sender construction, configuration parsing) are acceptable; data-path allocations are not. + +**Agent 7 — Test review & coverage:** Coverage gaps, error path tests, NULL/edge-case tests, boundary conditions, regression tests, test quality. Check: +- Rust unit tests in `questdb-rs/src/**/tests.rs` and `#[cfg(test)] mod tests` blocks +- C++ doctest tests in `cpp_test/test_line_sender.cpp` +- Python system tests in `system_test/test.py` +- Examples in `examples/` still build and run + +Cross-reference 2.5d: every cross-context exposure should have a test that exercises the changed symbol from that context. Missing tests for cross-context callsites is a high-priority finding. + +**Agent 8 — Code quality & API design:** Public API ergonomics and consistency, backward compatibility of C/C++ headers, naming conventions, dead code, documentation for public items (`///` docs on public Rust, Doxygen-style comments on C/C++ headers), `clippy` and `cargo fmt` compliance. + +**Agent 9 — Cross-context caller impact:** Walk the callsite inventory from 2.5b. For every callsite, fetch the surrounding code (the calling function plus its callers up two levels) and answer: + +- Does this caller pass inputs the new behavior handles incorrectly? +- Does this caller depend on a contract from the implicit contract list (2.5c) that the change broke? +- Is this caller in a context (FFI entry, async runtime, holding a lock, error path, hot loop, auto-flush callback, TLS handshake, panic-unwind boundary, `Drop` impl) where the new behavior misbehaves even if the inputs are valid? +- For changed Rust traits: do all impls still satisfy the new contract? +- For changed `extern "C"` signatures: does the C header still match? Do the C++ wrapper and Python ctypes binding still pass the right types and lifetimes? +- For changed buffer/sender state machines: do all callers respect the new state transitions (e.g., is a buffer cleared after error before being reused; is `flush` called only when the sender is in a flushable state)? + +This agent's output is structured per callsite, not per failure mode. Each callsite gets a verdict: SAFE / BROKEN / NEEDS VERIFICATION. Every BROKEN entry is a P0 finding regardless of whether the file is in the diff. + +This agent is not optional even when the diff is small. Small diffs to widely-used symbols (`Buffer::column_*`, `Sender::flush`, FFI exports) have the largest blast radius. + +**Agent 10 — Fresh-context adversarial:** Dispatched separately from agents 1-9 to escape checklist anchoring. This agent operates under different rules from the rest: + +- It receives ONLY the PR diff and the names of the changed files. It does NOT receive the change surface map from Step 2.5, the implicit contract list, the cross-context exposure list, or any of the review checklists below. +- Its sole instruction: "find ways this code is wrong". No category list, no failure-mode taxonomy, no QuestDB-specific style guide. +- It is free to use Read, Grep, and Glob to explore the repository however it wants. +- Findings are not pre-classified by category. Each finding states: what's wrong, why it's wrong, and the code path that demonstrates it. + +The point of this agent is to surface bugs the structured agents cannot see because they are reasoning inside the same frame. A finding here that none of agents 1-9 produced is high signal — it means the structured review missed it. A finding here that overlaps with agents 1-9 is corroboration. + +Run this agent in parallel with agents 1-9. It is mandatory regardless of diff size. + +Combine all agent findings into a single deduplicated **draft** report. Do NOT present this draft to the user yet — it goes straight into verification. + +## Step 3b: Verify every finding against source code + +The parallel review agents work from the diff plus the change surface map and frequently produce false positives — especially around memory ownership, `unsafe` blocks, FFI lifecycle conventions, and Rust control-flow guarantees. Every finding MUST be verified before it is reported. + +For each finding in the draft report: + +1. **Read the actual source code** at the exact lines cited. Do not rely on the agent's description alone. +2. **Trace the full code path**: follow callers, trait implementations, and generic instantiations. A method called on a trait object may dispatch to a specific impl. +3. **Check both sides of FFI boundaries**: if a finding involves Rust↔C/C++ interaction, read both the Rust FFI function and the C/C++ header/caller (and Python ctypes binding when applicable). Verify ownership transfer, error propagation, and cleanup on both sides. +4. **For resource leak claims**: trace every allocation to its corresponding free/drop on ALL code paths (happy path, error path, `?` operator early returns, panic unwind). Check for `Drop` impls. Before claiming a leak between allocation and cleanup registration, verify that the intervening code can actually fail. +5. **For Rust panic claims**: verify whether the panic site is actually reachable. Trace control flow backwards — a preceding guard, match arm, or early return may make it unreachable. +6. **For Rust panic claims via FFI**: trace the C/C++/Python caller to check whether it can actually pass parameters that trigger the panic. If every documented caller validates inputs before the FFI call AND the FFI function itself has a NULL/range guard, the panic is unreachable from the contract — drop it. But if the FFI function is the validation boundary, the panic IS reachable and must be flagged. +7. **For Rust numeric overflow claims**: check whether the overflow is reachable at realistic scale. The QuestDB client handles ILP messages — practical bounds are buffers up to a few hundred MB, message counts up to millions per flush, columns per row in the tens to low hundreds, symbol cardinality in the thousands. If overflow requires values beyond that scale (buffer sizes near `usize::MAX`, billions of columns), drop it. +8. **For unsafe soundness claims**: verify whether the safety invariants are actually violated. Check preconditions established by callers and documented in the `// SAFETY:` comment. +9. **For performance claims**: check whether the cost is measurable on a realistic workload. Downgrade to a nit if the saving is negligible relative to the surrounding I/O. Exception: an allocation on the per-row buffer-write path is always worth flagging, even a single one. +10. **For cross-context findings (Agent 9)**: re-read the callsite in full, including its callers up two levels, and confirm the broken behavior is reachable from production code paths or test paths users will exercise. Cross-context findings are high-value but also the easiest to overstate — verify carefully. + +**Classify each finding** as: +- **CONFIRMED in-diff** — the bug is real and inside the diff +- **CONFIRMED at out-of-diff callsite** — the bug is in an unchanged file because the changed symbol is used there in a way that's now broken (cite the file and the contract from 2.5c that was violated) +- **FALSE POSITIVE** — the code is actually correct (explain why) +- **CONFIRMED with nuance** — the issue exists but is less severe than stated (explain) + +**Move false positives to a separate "Downgraded" section** at the end of the report. For each, give a one-line explanation of why it was dismissed. This lets the PR author verify the reasoning and catch verification mistakes. + +Launch verification agents in parallel where findings are independent. Each verification agent should read surrounding source files, not just the diff. + +## Review checklists + +Review the diff for: + +### Correctness & bugs +- NULL/None handling at API boundaries +- Edge cases and error paths +- Logic errors, off-by-one, incorrect bounds, wrong operator precedence +- Integer overflow and truncation (especially in buffer size calculations and length parameters) +- Correct ILP wire format (v1 / v2) +- **Reachability expansion:** for each changed symbol, list the FFI surfaces, async contexts, error paths, lock-held states, and host languages it can now appear in but didn't before. Verify it works in each. + +### Rust safety +- No `unwrap()`/`expect()` in library code (only in tests, or on infallible paths with a `// SAFETY:` / `// INVARIANT:` justification) +- All `unsafe` blocks have documented safety invariants +- No undefined behavior: dangling pointers, use-after-free, double-free, data races +- Proper `Send`/`Sync` bounds on public types +- No panics that can escape FFI boundaries (every `extern "C"` function uses `catch_unwind` or proves panics are impossible) + +### Crash surface +Anything that aborts the Rust side aborts the host process. Beyond panics, check for: +- Direct aborts: `std::process::abort()`, `libc::abort()`, `std::intrinsics::abort()` +- Allocation-failure aborts: any allocation sized by an untrusted length parameter must validate the bound before allocating (Rust's default allocator aborts on OOM) +- Stack overflow: unbounded recursion, recursive `Drop` impls, deeply nested untrusted input +- Panic-in-`Drop` / double-panic during unwind — every `Drop` impl must be panic-free +- C++ exceptions escaping the wrapper into C callers — inline forwarders reachable from C must be `noexcept` or proven not to throw +- SIGPIPE on broken sockets — TCP/HTTP write paths must use `MSG_NOSIGNAL` or mask SIGPIPE +- Mutex poisoning — `Mutex::lock().unwrap()` panics if the mutex is poisoned by a prior panic; use `lock()` with explicit poison handling + +### FFI boundary +- All pointer arguments validated for NULL before dereference +- Length parameters validated against zero / overflow before slicing +- Error codes and error messages propagated correctly across FFI (no information loss) +- Ownership semantics clear and correct (caller-owned vs callee-owned, who frees) +- C header (`line_sender.h`) and C++ wrapper (`line_sender.hpp` and split headers) match Rust implementation signatures +- `cbindgen.toml` produces output consistent with handwritten headers when both exist +- String handling: UTF-8 validation, NUL termination, explicit length parameters, no embedded NULs unless documented +- ABI stability: struct layouts not reordered, enum discriminants not renumbered, calling conventions unchanged + +### Concurrency +- Race conditions: unsynchronized shared mutable state, missing memory barriers, unsafe publication +- Lock ordering issues that could deadlock (especially around tokio's blocking vs async distinction) +- Thread-safety of types passed across `Send` boundaries +- For every changed symbol, check whether it is now reachable from a thread or context (per 2.5d) where the previous concurrency assumptions don't hold + +### Performance +- Performance regressions: changes that make hot paths slower +- Unnecessary allocations in buffer building, column writes, or flushing paths +- Excessive copying of data that could be passed by reference / slice +- Buffer growth strategy (exponential vs linear) +- Syscall overhead (batching, buffering, TCP write coalescing) +- Algorithmic complexity at realistic scale: millions of rows per flush, hundreds of columns per row. Flag O(n²) on any data path. + +### Resource management +- Resources properly cleaned up on all code paths (especially error paths) +- `Drop` implementations correct and complete +- Socket/connection cleanup on error +- TLS session cleanup +- Buffer memory freed correctly +- No leaks through FFI boundary (callee returns ownership and caller frees, or vice versa — documented and consistent) + +### Code quality +- Public API is consistent and ergonomic +- Backward-compatible changes to C/C++ headers (or breaking changes are intentional and called out in the PR body) +- Naming conventions consistent with existing codebase +- No dead code or unused imports +- `clippy` clean (no `#[allow]` attributes added without justification) +- `cargo fmt` applied + +### Test review +- **Coverage gaps:** For every new or changed code path, verify a corresponding test exists. If not, flag it explicitly as "missing test for X". +- **Cross-context coverage:** For every entry in the cross-context exposure list (2.5d), verify a test exercises the changed symbol from that context. Missing cross-context tests are high-priority findings. +- **Error path coverage:** Are failure cases, partial writes, connection drops, TLS failures, auth failures, and edge conditions tested — not just the happy path? +- **NULL/edge-case tests:** Are NULL inputs, empty buffers, zero-length strings, max-length symbols, and boundary values tested? +- **FFI tests:** Are C/C++ API changes covered by tests in `cpp_test/`? +- **Integration tests:** Are protocol-level changes covered by system tests in `system_test/`? +- **Test quality:** Are tests actually asserting the right thing? Watch for tests that pass trivially or assert on wrong values. +- **Regression tests:** If this PR fixes a bug, is there a test that reproduces the original bug and would fail without the fix? + +### Unresolved TODOs and FIXMEs +- Scan the diff for `TODO`, `FIXME`, `HACK`, `XXX`, and `WORKAROUND` comments. For each one found: + - Is it a pre-existing comment that was just moved/reformatted, or newly introduced in this PR? + - If newly introduced: does it represent unfinished work that should block the merge, or a known limitation that is acceptable to ship? Flag any that look like deferred bugs or incomplete implementations. + - If the TODO references a ticket/issue number, verify the reference exists. + +### Commit messages +- Plain English titles, under 50 chars +- Active voice, naming the acting subject + +## Step 4: Output + +Present ONLY verified findings (false positives are excluded from Critical/Moderate/Minor). Structure as: + +### Critical +Issues that must be fixed before merge. Each must include: +- Exact file path and line numbers (including out-of-diff files) +- Whether the finding is **in-diff** or **out-of-diff** +- Code path trace showing why the bug is real +- For out-of-diff findings: the contract from 2.5c that was violated and the callsite that triggers it +- Suggested fix + +### Moderate +Issues worth addressing but not blocking. + +### Minor +Style nits and suggestions. + +### Downgraded (false positives) +Findings from the initial review that were dismissed after source code verification. For each, state: +- The original claim (one line) +- Why it was dismissed (one line, citing the specific code that disproves it) + +### Summary +- One-line verdict: approve, request changes, or needs discussion +- Highlight any regressions or tradeoffs +- State how many draft findings were verified vs dropped as false positives (e.g., "8 findings verified, 4 false positives removed") +- State the in-diff vs out-of-diff split (e.g., "5 findings in-diff, 3 findings out-of-diff"). If the diff is non-trivial and out-of-diff is zero, the cross-context pass likely underran — re-invoke Agent 9 with a wider grep before finalizing. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0cad2861 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,465 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +.NET client library for QuestDB. Covers both **ingestion** (HTTP / TCP / WS) and **read-side egress** (QWP over WS). Three ingest transports plus a dedicated query client: + +- **HTTP / HTTPS** — InfluxDB Line Protocol (ILP), recommended for most ingest workloads. +- **TCP / TCPS** — ILP over raw TCP, ECDSA P-256 auth, kept for low-overhead deployments. +- **WS / WSS (QWP) — ingest** — QuestDB's binary **columnar** wire protocol over + WebSocket (`/write/v4`). Higher throughput than ILP for wide rows, exposes the full + QuestDB type system (int8/int16/int32, float32, char, date, + timestamp-nanos, uuid, varchar, geohash, decimal128, long256, double + arrays, long arrays, Gorilla DoD timestamp compression). Always routed + through the **cursor send engine**: every appended frame lands in a segment + ring (RAM-backed by default, mmap-backed when `sf_dir` is set), is shipped + asynchronously, and is replayed on transient WS reconnects. Setting + `sf_dir` switches the segment backing to mmap files so frames survive a + process restart. +- **WS / WSS (QWP) — egress** — read-side WebSocket (`/read/v1`) that + streams query results as binary `RESULT_BATCH` frames. Surfaced through + a separate `QueryClient.New(...)` factory (not `Sender.New`) returning + `IQwpQueryClient`. Distinct connect-string parser (`QueryOptions`) with + egress-specific keys: `target=any|primary|replica`, + `compression=auto|raw|zstd`, `failover_*`, `initial_credit`. Decoder + pools per-column scratches across batches; bind-parameter wire format + is pinned by per-type byte vectors in `QwpBindValuesVectorsTests`. + +NuGet package id: `net-questdb-client`. Multi-targets `net6.0;net7.0;net8.0;net9.0;net10.0`. The +`ws::`/`wss::` (QWP) sender and the egress query client both require **net7.0+** because they +depend on `ClientWebSocket.HttpResponseMessage` for header-aware handshake; HTTP +and TCP senders work on every supported target. + +## Commands + +```bash +# Build the whole solution. +dotnet build net-questdb-client.sln -c Release + +# Full test pass excluding the three integration suites and the integration fuzz +# (which need a live QuestDB). Mirrors the CI filter applied to every leg except +# Linux + net9.0. +dotnet test src/net-questdb-client-tests/net-questdb-client-tests.csproj \ + --framework net10.0 -c Release \ + --filter "FullyQualifiedName!~QuestDbIntegrationTests&FullyQualifiedName!~QuestDbWebSocketIntegrationTests&FullyQualifiedName!~QuestDbQueryIntegrationTests" + +# Run a single test or namespace via NUnit's name filter. +dotnet test src/net-questdb-client-tests/net-questdb-client-tests.csproj \ + --framework net10.0 -c Release \ + --filter "FullyQualifiedName~QwpEncoder" + +# Integration tests. All three suites (HTTP/TCP, WebSocket ingest, Egress query) +# plus the integration fuzz suites (FullyQualifiedName~QuestDb & ~Fuzz) are plain +# `[TestFixture]` and lean on `QuestDbManager`, which boots QuestDB as a local +# java process — no Docker. The WS / egress endpoints (`/write/v4`, `/read/v1`) +# only exist on master, so point QuestDbManager at a built master repo via +# `QUESTDB_REPO`, or a built jar via `QUESTDB_JAR`. `java` resolves from +# `JAVA_HOME` or `PATH`. `QDB_LIVE_HTTP` / `QDB_LIVE_ILP` skip launching entirely +# when pointed at an already-running instance. +QUESTDB_REPO=/path/to/built/questdb \ + dotnet test src/net-questdb-client-tests/net-questdb-client-tests.csproj \ + --framework net10.0 -c Release \ + --filter "FullyQualifiedName~QuestDbIntegrationTests|FullyQualifiedName~QuestDbWebSocketIntegrationTests|FullyQualifiedName~QuestDbQueryIntegrationTests" + +# Single-suite drilldowns: +QUESTDB_REPO=/path/to/built/questdb \ + dotnet test ... --filter "FullyQualifiedName~QuestDbWebSocketIntegrationTests" +QDB_LIVE_HTTP=127.0.0.1:9000 QDB_LIVE_ILP=127.0.0.1:9009 \ + dotnet test ... --filter "FullyQualifiedName~QuestDbQueryIntegrationTests" + +# Benchmarks. Names match the BenchmarkDotNet 0.13 filter syntax +# (`Param: value` colon-space, NOT the [Param=value] display format). +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchInsertsWs*' '*BenchSfThroughput*' + +# Egress throughput bench. Requires a live master endpoint. +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchQueryWs*' + +# Example apps under src/example-* are compilable demos referenced by +# examples.manifest.yaml (rendered on questdb.io). Keep paths and file +# names stable when editing. +dotnet run --project src/example-qwp-ingest --framework net10.0 +``` + +There is no Makefile. The CI definition is `azure-pipelines.yml` at the +repo root; it runs `dotnet test` against net8.0/net9.0 on Linux and +Windows runners. + +## Architecture + +### Senders and entry points + +The public surface is the `ISender` interface in +`src/net-questdb-client/Senders/ISender.cs`. All concrete senders +(`HttpSender`, `TcpSender`, `QwpWebSocketSender`) implement it. QWP +adds a **superset** interface `IQwpWebSocketSender : ISender` with +QWP-only methods (`Ping`, `GetHighestAckedSeqTxn`, +`GetHighestDurableSeqTxn`); callers wanting those cast the returned +`ISender` to `IQwpWebSocketSender`. + +Two factories are the only entry points: + +- `Sender.New(string confStr)` — parses a config string via + `SenderOptions(confStr)` (`Utils/SenderOptions.cs`). Supported schemes: + `http`, `https`, `tcp`, `tcps`, `ws`, `wss`. +- `Sender.New(SenderOptions options)` / `options.Build()` — programmatic + configuration. Both paths funnel through `Sender.New(SenderOptions)`, + which calls `options.EnsureValid()` (auth, TLS, multi-addr, gzip, + WS-only-keys-on-non-WS heuristic, auto-flush normalisation) before + dispatching on `options.protocol`. + +`HttpSender` and `TcpSender` extend `AbstractSender`; the buffer is the +ILP-row-oriented `IBuffer` (`Buffer.cs`, `BufferV1.cs`, `BufferV2.cs`) +with three protocol versions (V1 text-only, V2 adds binary `float64` and +n-dimensional `float64` arrays, V3 adds decimals). `QwpWebSocketSender` +does **not** extend `AbstractSender` — QWP is multi-table columnar and +manages its own per-table column buffers (`QwpTableBuffer`) plus a +schema cache; the `IBuffer` text-row model doesn't fit. + +HTTP auto-negotiates protocol version via `/settings`; TCP requires +`protocol_version=2|3` to opt into V2/V3. When adding a new ILP column +type expect to touch `IBuffer`/`BufferV1`/`BufferV2`, all three V1/V2/V3 +HttpSender variants, the TcpSender variants, the `ISender`/`ISenderV1`/ +`ISenderV2` interfaces, and the sender tests under `BufferTests`, +`HttpTests`, `LineTcpSenderTests`. + +### QWP (WebSocket columnar protocol) + +QWP is not a version of ILP — it is a distinct binary protocol with its +own framing, codecs, and server handshake. Everything QWP lives in +`src/net-questdb-client/Qwp/`: + +- `QwpConstants.cs` — magic (`"QWP1"`), header flags (Gorilla, delta + symbol dictionary), type codes, ACK status codes. Schemas are always + inline — there is no schema-mode byte or schema-id on the wire. + Self-sufficient framing is a client-side mode (full symbol dict per + frame), not a wire flag. +- `QwpVarint.cs` / `QwpBitWriter.cs` — wire primitives. Unsigned LEB128 + varints capped at 10 bytes (`MaxBytes`) with strict overflow rejection. + `QwpBitWriter` / `QwpBitReader` are LSB-first bit-packers with upfront + capacity validation so all-zero bitstreams can't silently advance past + the buffer end. +- `QwpColumn.cs` — per-type columnar storage. Fixed-width types share + `FixedData`/`FixedLen`; varchar uses `StrOffsets`/`StrData`/`StrLen`; + symbols use `SymbolIds` (global dict ids); booleans bit-pack into + `BoolData`. Null tracking via lazy `NullBitmap` allocated only when a + null appears. Decimal scale and geohash precision are locked on first + non-null write. Arrays serialise into `FixedData` as + `[u8 nDims][i32...]shape[values]`. +- `QwpTableBuffer.cs` — per-table buffer. Owns an ordered `List` + + designated-timestamp column slot. Per-row state machine: caller + invokes `AppendXxx` to set values, then `At*` to commit (null-pads + untouched columns). Failure of any single Append rolls back the + *entire* row via `CancelCurrentRow()` (`QwpColumn.Savepoint` per + touched column + drop columns added since `_committedColumnCount`), + so the caller sees consistent buffer state on any error path. +- `QwpEncoder.cs` — assembles the multi-table QWP frame from a set of + table buffers in one flush. Each table block carries its column schema + inline (no schema-id / schema-mode byte); the encoder is stateless + across flushes. +- `QwpSymbolDictionary.cs` — global symbol-id allocation + delta + encoding. The ingest sender always encodes in self-sufficient mode + (`selfSufficient: true`), re-emitting the full symbol dict on every + frame so the cursor engine can replay un-acked frames after a + reconnect without server-side cache state. +- `QwpGorilla.cs` — delta-of-delta timestamp compression. The encoder + emits a 1-byte encoding flag (`0x00` uncompressed, `0x01` Gorilla) + only when `FLAG_GORILLA` is set on the message header. Falls back to + uncompressed when the column has < 3 non-null values or any DoD + exceeds int32. Always-on; no opt-in flag. +- `QwpResponse.cs` — ACK / error frame parser. Strict UTF-8 (throws on + invalid bytes) for error messages and per-table names; rejects empty + table names, lying lengths, and trailing bytes after the last entry. +- `QwpWebSocketTransport.cs` — thin wrapper over + `System.Net.WebSockets.ClientWebSocket`. Performs the `/write/v4` + (ingest) or `/read/v1` (egress) upgrade — path is parameterised via + `QwpWebSocketTransportOptions.Path`. QWP version-negotiation headers + (`X-QWP-Max-Version`, `X-QWP-Client-Id`); optional dump stream + records binary frames in both directions, serialised under + `_dumpLock` because send/receive run concurrently. +- `Utils/QwpTlsAuth.cs` — shared `BuildAuthHeader` (Basic auth / raw + header) and `BuildCertificateValidator` (TLS verify + custom CA + PFX). Used by both the ingest sender and the egress query client so + TLS / auth behaviour stays consistent. +- `Qwp/Query/` — egress (read-side) subsystem. Distinct entry surface + (`QueryClient.New`) and codec from the ingest encoder; shares only + wire primitives + `Utils/QwpTlsAuth.cs`. + - `QueryOptions.cs` — egress connect-string parser. Disjoint from + `SenderOptions` (egress fields like `target=`, `compression=`, + `failover_*`, `initial_credit` don't bleed into ingest validation). + - `QwpQueryWebSocketClient.cs` — owns the WS connection + (`/read/v1` upgrade), I/O loop, and per-query state machine. + Sends `QUERY_REQUEST` / `CANCEL` / `CREDIT` frames *without* the + 12-byte QWP1 header (asymmetric framing: server→client wraps the + header, client→server is payload-only with `msg_kind` at byte 0). + `AuthError` is immediately terminal — no failover retry. + - `QwpResultBatchDecoder.cs` — decodes `RESULT_BATCH` payloads + column-major. Per-column scratches (`ValueBytes`, `StringHeap`, + `NonNullIndex`, `StringOffsets`) survive `ColumnView.Reset()` and + grow-and-reuse across batches. ColumnView slots themselves reuse + via `ConfigureColumn` / `TrimToColumnCount`, so a fresh batch on + the same schema does not churn schema metadata. + - `QwpColumnBatch.cs` / `QwpColumnBatchHandler.cs` — column-major + view + abstract handler the user implements. Span-returning + accessors (`GetStringSpan`) are valid only for the duration of + `OnBatch`. + - `QwpBindValues.cs` / `QwpBindSetter.cs` — typed bind-parameter + builder. 18 wire types, ascending-index validation, decimal scale + + geohash precision range checks. Wire-format byte layout pinned + per type in `QwpBindValuesVectorsTests`. + - `QwpEgressConnState.cs` — per-connection symbol dict + schema + registry. State spans multiple batches so it lives outside + `QwpColumnBatch`. + - `QwpServerInfo.cs` / `QwpRoleMismatchException.cs` — server + identity + the `target=` filter rejection. SERVER_INFO is emitted + unconditionally on connect; failover walks `addr=` candidates + filtering against role. +- `Senders/QwpWebSocketSender.cs` — owns the lifecycle. Single + execution path through `QwpCursorSendEngine` regardless of mode — the + engine uses pipelined double-buffering bounded by the FSN ring + capacity rather than a numeric in-flight window: + - **RAM mode** (default, `sf_dir` unset): the engine sits over a + memory-backed `QwpSegmentRing` (`OpenMemoryBacked`) of + `QwpMemorySegment`s, capped at `sf_max_total_bytes` (default + 128 MiB; 4 MiB per segment). No persistence; segments are + `NativeMemory.Alloc`'d and freed on trim. + - **SF mode** (`sf_dir=...` set): the engine sits over a + file-backed `QwpSegmentRing` (`Open`) of mmap'd + `QwpMmapSegment`s, capped at `sf_max_total_bytes` (default + 10 GiB). Frames survive process crashes and replay on next + startup. + - In both modes a wire failure (server close, transient I/O, + timeout) triggers `QwpReconnectPolicy` backoff. The sender only + becomes terminal on auth/upgrade-reject, protocol violation, or + reconnect-budget exhaustion — `flush()` does **not** throw on + transient disconnects. + +### Cursor send engine + segment backings + +Lives under `Qwp/Sf/`. The cursor engine is the universal hot path; +segments are an abstraction (`IQwpSegment`) over either +`QwpMmapSegment` (file-backed, used when `sf_dir` is set) or +`QwpMemorySegment` (RAM-backed, used when `sf_dir` is null). + +- `IQwpSegment.cs` — common segment contract used by the ring. + Implementations: `QwpMmapSegment` (file-backed) and + `QwpMemorySegment` (RAM-backed). +- `QwpFiles.cs` — exclusive-locking file ops. `OpenExclusive` / + `TryOpenExclusive` use `FileShare.None` as a portable advisory lock + (held for the lifetime of the returned `FileStream`). SF is documented + as **local filesystem only** — `FileShare.None` is unreliable on NFS + / SMB. `LooksLikeNetworkPath` heuristic guards `QwpSlotLock.Acquire`, + which throws when the slot path is on a UNC mount. +- `QwpSlotLock.cs` — per-sender lock file (SF mode only). `Acquire` uses + `TryOpenExclusive`; liveness is signalled by holding the OS file lock + for the slot's lifetime — release on process exit (graceful or crashed) + is what makes the slot eligible for adoption by the orphan scanner. + A best-effort `.heartbeat` mtime is refreshed by the segment manager + every ~1s for observability; it is not consulted by the lock contention + test. +- `QwpMmapSegment.cs` — single mmap'd segment file with envelope frames + `[u32 crc32c][u32 frame_len][frame bytes]`. Replays on open via + `ScanForLastGoodEnvelope`, which walks every envelope verifying its + CRC32C and stops at the first torn tail (oversized length, declared + length past EOF, or CRC mismatch), truncating it. `Seal()` only marks + the segment immutable and flushes — there is no recovery sidecar, so + the `.sfa` file stays byte-identical to the spec layout. +- `QwpMemorySegment.cs` — RAM-backed segment via + `NativeMemory.Alloc` / `Free`. Same envelope wire format as mmap + (preserves CRC verification on read); recovery and replay don't apply. +- `QwpSegmentRing.cs` — ring of active + sealed segments. File-backed + rings (`Open`) use a hot-spare-path mechanism (manager pre-creates + `.tmp` files, producer `File.Move`s them into place) so producer + never blocks on disk allocation. Memory-backed rings + (`OpenMemoryBacked`) skip the hot-spare path: producer allocates + inline (cheap), and the cap is enforced directly via + `SetMaxTotalBytes`. `IsMemoryBacked` flips many conditional paths. +- `QwpSegmentManager.cs` — manager thread: heartbeat-driven (~1s) + plus callback-driven. File mode: provisions hot spares, trims + acked segments by unlinking files, refreshes the slot heartbeat, + flushes the active mmap segment to bound the host-crash data-loss + window. Memory mode: only trims (free instead of unlink); spare + provisioning is a no-op. +- `QwpCrc32C.cs` — software slice-by-8 CRC32C, reflected polynomial + `0x82F63B78`. Deliberately avoids `System.IO.Hashing.Crc32C` and + hardware intrinsics so output is bit-identical across runtime + versions and CPU architectures, which envelope verification by + peer clients depends on. +- `QwpCursorSendEngine.cs` — pipelined send + receive pumps over a + reconnecting transport. State guarded by `_stateLock`; awaiters + signalled via `_appendSignal` / `_ackSignal` (TaskCompletionSource + with `RunContinuationsAsynchronously`). Both signals are fired via + `Task.Run(() => prev.TrySetResult(true))` to bounce off the lock + holder's stack — direct `TrySetResult` triggered an intermittent + Linux + .NET 9 deadlock under `Task.WaitAsync` continuation chains + in `EndToEnd_Sf_SingleRow_FrameReachesServerAndIsSelfSufficient`. +- `QwpReconnectPolicy.cs` — exponential backoff with saturation on + ticks (avoids `long` overflow when `InitialBackoff` is days-scale). +- `QwpOrphanScanner.cs` / `QwpBackgroundDrainer.cs` / + `QwpBackgroundDrainerPool.cs` — adopts crashed sibling slots (other + `sender_id`s under the same `sf_dir`) and drains their pending + segments. Drainer pool uses a two-phase shutdown that **leaks the + semaphore** rather than risk `ObjectDisposedException` on late + WaitAsync/Release from un-joined drainer tasks. +- `SfCleanup.cs` — best-effort exception swallower for cleanup paths. + Recurses into `AggregateException` so a wrapped real failure isn't + silently masked by the cleanup-error allowlist. + +### Config string reference + +`Utils/SenderOptions.cs` is the single source of truth. Notable +behaviours: + +- `username` / `password`: Basic auth for HTTP **and WS**; for TCP, + `username` is the ECDSA key id (kid) and `token` is the secret — + **`username` + `token` together is valid for TCP** (mutually + exclusive only for HTTP/WS). `ValidateAuthCombination` checks + `IsTcp()` first and returns early. +- `token_x` / `token_y`: silently accepted for cross-client config-string + interop; ignored at runtime. +- `request_durable_ack`, `sf_*`, `reconnect_*`, `initial_connect_retry`, + `initial_connect_mode`, `close_flush_timeout_millis`, `drain_orphans`, + `max_background_drainers`, `sender_id`, `ping_timeout`, the `on_*_error` + policy keys, and the egress-only keys (`target`, `compression`, + `failover_*`, `initial_credit`): WS-only. Rejected on non-WS schemes + via `ValidateWebSocketKeys` (string-ctor path) or + `ValidateWebSocketKeysAgainstDefaults` (programmatic-init path, + default-comparison heuristic). +- `auto_flush=off` zeros `auto_flush_rows` / `auto_flush_bytes` / + `auto_flush_interval` to `-1`. WS-specific defaults + (`auto_flush_rows=1000`, `auto_flush_bytes=8 MiB`, `auto_flush_interval=100ms`) + only apply when `auto_flush != off` — `auto_flush=off` is honoured even for ws. + The WS byte budget is further clamped at runtime to 90% of the server's + advertised `X-QWP-Max-Batch-Size` (when present). +- `tls_verify=unsafe_off` accepts any server cert (dev / self-signed + only — never ship to prod). +- `tls_roots`, `tls_roots_password`: PFX path + optional password for + pinning a custom CA bundle. +- Multiple `addr=` entries are supported on HTTP/HTTPS (failover via + `AddressProvider`) and on WS/WSS (role-aware skipping via + `QwpHostHealthTracker`). TCP/TCPS reject multi-addr. +- `gzip=on` rejected for ws/wss (binary protocol; the WS-only key + check is value-based so it works for both string-ctor and + programmatic init paths). +- `ToString()` skips WS-only keys when the protocol is non-WS so the + output round-trips through `new SenderOptions(s.ToString())`. + +### Connection pooling + +HTTP is thread-safe at the underlying `HttpClient` level; the Sender +itself is **not** thread-safe — one Sender per producer thread, or wrap +your own pool. There is no in-tree `LineSenderPool`; the HTTP transport +already shares `HttpClient`s under the hood via `IHttpClientFactory` +semantics in `HttpSender`. WS / SF manage their own concurrency model +(in-flight window, slot lock) and explicitly reject pooling. + +### Value types + +- `Decimal` is the BCL 96-bit `System.Decimal`; `IBuffer.Column(name, + decimal)` and `QwpColumn.AppendDecimal128` both take it. The QWP wire + format is fixed-width Decimal128 (16 bytes, two's-complement signed + scale-locked on first non-null write). +- Arrays for QWP are `ReadOnlySpan` / `ReadOnlySpan` plus + a `ReadOnlySpan` shape. Total element count is bounded by the + caller; the encoder writes `[u8 nDims][i32]*nDims[values]` as a + single packed payload into `FixedData`. + +## Testing layout + +- Unit tests in `src/net-questdb-client-tests/`: + - `BufferTests.cs`, `HttpTests.cs`, `LineTcpSenderTests.cs`, + `MultiUrlHttpTests.cs`, `SenderOptionsTests.cs` — ILP / config / + failover, no Docker. + - `Qwp/QwpEncoderTests.cs`, `Qwp/QwpColumnTests.cs`, + `Qwp/QwpTableBufferTests.cs`, `Qwp/QwpVarintTests.cs`, + `Qwp/QwpResponseTests.cs`, `Qwp/QwpSymbolDictionaryTests.cs`, + `Qwp/QwpGorillaTests.cs`, `Qwp/QwpWebSocketTransportTests.cs`, + `Qwp/QwpWebSocketSenderTests.cs` — QWP-side unit + component tests + using `DummyQwpServer` (`src/dummy-http-server/DummyQwpServer.cs`) + as a Kestrel-backed in-process WebSocket server. + - `Qwp/Sf/Qwp*Tests.cs` — store-and-forward subsystem tests + (file ops, segment ring/manager, drainers, cursor engine, reconnect + policy, slot lock, orphan scanner). + - `Qwp/Query/*Tests.cs` — egress unit + e2e tests against + `DummyQwpServer` configured with `/read/v1` (`QueryOptionsTests`, + `QwpResultBatchDecoderTests`, `QwpBindValuesTests`, + `QwpRoleFilterTests`, `QwpQueryClientEndToEndTests`). + `QwpBindValuesVectorsTests` pins the bind-payload byte layout + per wire type. + - `Utils/QwpTlsAuthTests.cs` — auth / TLS helper unit tests shared + by ingest and egress. +- Integration tests — the three suites plus the integration fuzz suites + (`QuestDb*FuzzTests`) are plain `[TestFixture]` (no `[Explicit]`), + bootstrapped by `QuestDbManager`, which launches QuestDB as a local java + process (`java -p questdb.jar -m io.questdb/io.questdb.ServerMain`) — no + Docker. CI excludes the three suites by FQN on every leg except Linux + + net9.0; the integration fuzz self-skips (`Assert.Ignore`) when no QuestDB + build is configured. The WS (`/write/v4`) and egress (`/read/v1`) endpoints + only exist on master, so the jar must be a master build: set `QUESTDB_REPO` + (a built QuestDB repo — `core/target/**/questdb*-SNAPSHOT.jar`) or + `QUESTDB_JAR` (a direct jar path); `java` resolves from `JAVA_HOME` / `PATH`. + `QDB_LIVE_HTTP` / `QDB_LIVE_ILP` skip launching when pointed at an existing + instance. CI clones + `mvn` builds QuestDB master (see `ci/azure-pipelines.yml`). + - `QuestDbIntegrationTests.cs` — HTTP/TCP integration. + - `QuestDbWebSocketIntegrationTests.cs` — ingest WS/QWP integration. + - `QuestDbQueryIntegrationTests.cs` — egress integration. `OneTimeSetUp` + drops + re-seeds fixture tables so runs are idempotent against a + long-lived master instance. + - `QuestDbWebSocketIngestFuzzTests.cs` + `QuestDbEgress{,Bind,Alter,Fragmentation}FuzzTests.cs` + — QWP ingress/egress fuzz suites (ports of the Rust client's fuzz tests). + Offline egress fuzz that needs no server lives in + `Qwp/Query/QwpEgress{Bounds,Fragmentation}FuzzTests.cs`. Seeds print to the + test output; reproduce via `QWP_FUZZ_SEED` / `QWP_WS_FUZZ_SEED` / + `QWP_EGRESS_FUZZ_SEED`. +- `JsonSpecTestRunner.cs` — shared ILP conformance vectors + (`Json/specs/*.json`) driven via the `RunHttp` / `RunTcp` + `[TestCaseSource]` parameterisation. +- Benchmarks in `src/net-questdb-client-benchmarks/` (BenchmarkDotNet): + `BenchInsertsWs`, `BenchLatencyWs`, `BenchSfThroughput`, + `BenchSfAppend`, `BenchQueryWs`, plus the legacy ILP benches. The + ingest QWP suite uses + `[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory, + BenchmarkLogicalGroupRule.ByParams)]` with + `[BenchmarkCategory("Narrow"|"Wide"|"MultiTable")]` so each row + shape compares against its own HTTP baseline. `BenchQueryWs` uses + the same grouping (Narrow / Wide × 10k / 100k / 1M rows) and an + HTTP `/exec` baseline that parses `dataset[][]` so both methods do + equivalent extraction work; it declares its own job via + `[Config(typeof(QueryThroughputConfig))]` (20 iter × 5 warmup). + Senders / clients live in `[GlobalSetup]` so per-invocation cost + is the wire path, not handshake / mmap / engine spin-up. + +## Conventions + +- Apache-2.0 license banner at the top of every `.cs` file. Copy from + any existing source when adding a new file. +- Internal helpers are `internal` and tests reach them via + `[InternalsVisibleTo("net-questdb-client-tests")]` declared in + `net-questdb-client.csproj`. Don't make production code public to + satisfy a test — extend the friend list instead. +- Comments default to **none**. Only add a one-liner when the *why* is + non-obvious (a hidden constraint, a counter-intuitive ordering, a + workaround for a runtime quirk). Don't restate what the code does; + don't reference plan documents (e.g. "per Phase 4 §9") because those + references rot once the plan ships. +- Errors from `IBuffer` / `ISender.Column*` ILP methods are **latched + on the buffer** — the fluent API keeps returning the same sender, + the error surfaces on the next `At` / `AtNow` / `Flush`. `QwpTableBuffer` + takes a stricter line: any `AppendXxx` failure aborts the entire + in-progress row (`CancelCurrentRow`) so the buffer never carries a + partially-applied row. +- QWP frames are **always self-sufficient**: every frame carries the + full schema + full symbol-dictionary delta, regardless of whether + `sf_dir` is set. There is no reference-mode schema reuse on the + ingest path. This is what lets the cursor engine replay un-acked + frames after a transient WS reconnect (and lets segment files + replay against a fresh server in SF mode) without server-side + cache state. +- The WS sender requires net7.0+. Gate any new WS-related code behind + `#if NET7_0_OR_GREATER` if it touches `ClientWebSocket`-specific APIs; + HTTP and TCP code must continue to compile on net6.0. diff --git a/README.md b/README.md index bbbcba0d..d71e2a9f 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ the sender is disposed. using var sender = Sender.New("http::addr=localhost:9000;auto_flush=on;auto_flush_rows=1000;"); ``` -#### Flush every 5000 rows +#### Flush every 1000 rows (no time-based trigger) ```csharp using var sender = Sender.New("http::addr=localhost:9000;auto_flush=on;auto_flush_rows=1000;auto_flush_interval=off;"); @@ -110,13 +110,13 @@ using var sender = Sender.New("http::addr=localhost:9000;auto_flush=on;auto_flus #### HTTP Authentication (Basic) ```csharp -using var sender = Sender.New("https::addr=localhost:9009;tls_verify=unsafe_off;username=admin;password=quest;"); +using var sender = Sender.New("https::addr=localhost:9000;tls_verify=unsafe_off;username=admin;password=quest;"); ``` #### HTTP Authentication (Token) ```csharp -using var sender = Sender.New("https::addr=localhost:9009;tls_verify=unsafe_off;username=admin;token="); +using var sender = Sender.New("https::addr=localhost:9000;tls_verify=unsafe_off;username=admin;token="); ``` #### TCP Authentication @@ -125,6 +125,129 @@ using var sender = Sender.New("https::addr=localhost:9009;tls_verify=unsafe_off; using var sender = Sender.New("tcps::addr=localhost:9009;tls_verify=unsafe_off;username=admin;token=NgdiOWDoQNUP18WOnb1xkkEG5TzPYMda5SiUOvT1K0U=;"); ``` +### WebSocket / QWP (columnar binary, requires .NET 7+) + +The `ws::` and `wss::` schemes use the QuestDB columnar binary protocol (QWP) over a WebSocket. Compared to `http::` / `tcp::` (text ILP), QWP delivers higher sustained throughput at lower CPU cost — payloads are smaller because columns share schema once per connection. + +```csharp +using var sender = Sender.New("ws::addr=localhost:9000;"); +sender.Table("trades") + .Symbol("symbol", "ETH-USD") + .Column("price", 2615.54) + .Column("amount", 0.00044) + .At(DateTime.UtcNow); +sender.Send(); +``` + +`wss::` adds TLS: + +```csharp +using var sender = Sender.New("wss::addr=q.example.com:443;username=admin;password=quest;"); +``` + +#### Pipelined async mode + +By default the WebSocket sender pipelines batches in flight. Use the `*Async` API to keep the calling thread free while frames are on the wire: + +```csharp +await using var sender = Sender.New("ws::addr=localhost:9000;"); + +for (var i = 0; i < 1_000_000; i++) +{ + sender.Table("trades") + .Symbol("symbol", "ETH-USD") + .Column("price", 2615.54); + await sender.AtAsync(DateTime.UtcNow); +} + +await sender.SendAsync(); +``` + +#### Multi-address failover + +Pass a comma-separated list to `addr=` to enable role-aware failover across multiple QuestDB nodes: + +```csharp +using var sender = Sender.New("ws::addr=node-a:9000,node-b:9000,node-c:9000;"); +``` + +The sender walks the list in order. If a node returns `503 + X-QuestDB-Role`, it is skipped — `REPLICA` is shelved as structurally unwritable, `PRIMARY_CATCHUP` is treated as transiently unavailable, and the sender retries them after a backoff (`PRIMARY_CATCHUP` is preferred over `REPLICA` on retry since it tends to recover quickly). `PRIMARY` and `STANDALONE` accept the upgrade. Auth failures (`401`/`403`) remain terminal and do not fall through to the next address. + +In SF mode (`sf_dir=...`), the same rotation applies on every reconnect — when the active node loses its primary role, the engine's reconnect loop walks past the demoted node and picks up wherever the new primary lands. Backoff applies once per full round, not per host attempt. + +#### Examples + +Working sample projects (drop-in copies): + +- [`src/example-qwp-ingest`](src/example-qwp-ingest/Program.cs) — minimal `ws::` sender. +- [`src/example-qwp-ingest-auth-tls`](src/example-qwp-ingest-auth-tls/Program.cs) — `wss::` with Basic auth and a custom TLS root. +- [`src/example-qwp-query`](src/example-qwp-query/Program.cs) — `ws::` query client demo (basic / binds / errors). + +Run with `dotnet run --project src/example-qwp-ingest`. + +#### Gorilla timestamp compression + +Timestamp columns on the QWP (`ws::` / `wss::`) sender are automatically compressed with Gorilla delta-of-delta encoding. This is the best fit for steady-tick streams (sensor readings, evenly spaced ticks). The encoder transparently falls back to uncompressed per column when DoDs overflow int32. For irregular timestamps (event-driven workloads) the Gorilla form can be larger than the uncompressed values. + +#### Durable acknowledgements + +Set `request_durable_ack=on` to opt into per-table object-store watermarks. The sender exposes them via the `IQwpWebSocketSender` interface: + +```csharp +using var sender = Sender.New("ws::addr=localhost:9000;request_durable_ack=on;"); +sender.Table("trades").Column("v", 1L).At(DateTime.UtcNow); +sender.Send(); + +if (sender is IQwpWebSocketSender ws) +{ + long committed = ws.GetHighestAckedSeqTxn("trades"); // -1 if none yet + long durable = ws.GetHighestDurableSeqTxn("trades"); // requires the opt-in + ws.Ping(); // wait for in-flight to drain +} +``` + +#### Defaults + +| Knob | WebSocket default | HTTP / TCP for comparison | +|-------------------------------|-------------------|---------------------------------| +| Default port | 9000 | 9000 (HTTP), 9009 (TCP) | +| Endpoint path | `/write/v4` | `/write` (HTTP) | +| `auto_flush_rows` | 1000 | 75000 (HTTP), 600 (TCP) | +| `auto_flush_interval` | 100 ms | 1000 ms | +| `auto_flush_bytes` | `int.MaxValue` | `int.MaxValue` | +| `close_flush_timeout_millis` | 60000 ms | n/a | +| `request_durable_ack` | `off` | n/a | + +#### Store-and-forward (durable client buffer) + +Set `sf_dir=/path/to/dir` to opt into the on-disk store-and-forward buffer. Outgoing batches are persisted to mmap'd segments before going on the wire, and a background I/O thread silently reconnects + replays whatever's still on disk if the network drops or the process restarts. User code is shielded from transient disconnects; a `Send` can still surface terminal errors when the bounded retry / drain budgets (`sf_append_deadline_millis`, `reconnect_max_duration_millis`) expire. + +```csharp +using var sender = Sender.New( + "ws::addr=localhost:9000;sf_dir=/var/lib/myapp/qwp;sender_id=ingester-01;"); +``` + +Each sender owns one slot directory at `//`. `sender_id` defaults to `"default"` and **must be unique per process** sharing the same `sf_dir`. To reclaim slots left by a crashed sibling process, set `drain_orphans=on`: + +```csharp +using var sender = Sender.New( + "ws::addr=localhost:9000;sf_dir=/var/lib/myapp/qwp;sender_id=ingester-01;drain_orphans=on;"); +``` + +SF caveats: + +- **Local filesystem only.** `FileShare.None` advisory locking does not behave reliably on NFS or other networked filesystems. Point `sf_dir` at a local disk. +- **SF frames are larger.** The sender uses self-sufficient encoding (every frame carries the full schema + symbol dictionary) so any frame can be replayed against a fresh server connection. Expect somewhat larger payload-per-batch vs non-SF mode. +- Only `sf_durability=memory` is supported in v1 (matches Java). + +#### Caveats + +- **`ws::` / `wss::` requires .NET 7 or later.** HTTP and TCP transports keep working on net6.0. +- The transport disables HTTP proxies by default; long-lived WebSocket connections rarely survive them. Override with `proxy=system` to use the system proxy or `proxy=http://host:port` for an explicit URI. +- Multi-address `addr=h1,h2,...` is supported with role-aware failover (see "Multi-address failover" above). +- **Use long-lived senders.** WebSocket upgrade is significantly more expensive than an HTTP POST; create the sender once at startup and keep it alive for the process lifetime, rather than per request. +- **Connect-string quoting differs from Java/Go.** This client parses connect strings via `System.Data.Common.DbConnectionStringBuilder`, which uses ADO.NET-style `'`/`"` quoting with internal doubling. Java and Go implement `;;` → `;` escaping. A connect string with a literal semicolon in a value (rare; mostly passwords or paths) parses differently across clients — quote the value or escape per the local parser. + ### Multiple database endpoints The client can be configured with multiple `addr` entries pointing to different instances of QuestDB. @@ -147,8 +270,8 @@ The config string format is: | Name | Default | Description | | ------------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `protocol` (schema) | `http` | The transport protocol to use. Options are http(s)/tcp(s). | -| `addr` | `localhost:9000` | The {host}:{port} pair denoting the QuestDB server. By default, port 9000 for HTTP, port 9009 for TCP. | +| `protocol` (schema) | `http` | The transport protocol to use. Options are http(s)/tcp(s)/ws(s). `ws::` / `wss::` requires .NET 7+. | +| `addr` | `localhost:9000` | The {host}:{port} pair denoting the QuestDB server. Default port 9000 for HTTP and ws/wss, 9009 for TCP. | | `auto_flush` | `on` | Enables or disables auto-flushing functionality. By default, the buffer will be flushed every 75,000 rows, or every 1000ms, whichever comes first. | | `auto_flush_rows` | `75000 (HTTP)` `600 (TCP)` | The row count after which the buffer will be flushed. Effectively a batch size. | | `auto_flush_bytes` | `Int.MaxValue` | The byte buffer length which when exceeded, will trigger a flush. | @@ -158,19 +281,35 @@ The config string format is: | `username` | | The username for authentication. Used for Basic Authentication and TCP JWK Authentication. | | `password` | | The password for authentication. Used for Basic Authentication. | | `token` | | The token for authentication. Used for Token Authentication and TCP JWK Authentication. | -| `token_x` | | Un-used. | -| `token_y` | | Un-used. | | `tls_verify` | `on` | Denotes whether TLS certificates should or should not be verified. Options are on/unsafe_off. | -| `tls_ca` | | Un-used. | | `tls_roots` | | Used to specify the filepath for a custom .pem certificate. | | `tls_roots_password` | | Used to specify the filepath for the private key/password corresponding to the `tls_roots` certificate. | | `auth_timeout` | `15000` | The time period to wait for authenticating requests, in milliseconds. | -| `request_timeout` | `10000` | Base timeout for HTTP requests before any additional time is added. | +| `request_timeout` | `30000` | Base timeout for HTTP requests before any additional time is added. | | `request_min_throughput` | `102400` | Expected minimum throughput of requests in bytes per second. Used to add additional time to `request_timeout` to prevent large requests timing out prematurely. | | `retry_timeout` | `10000` | The time period during which retries will be attempted, in milliseconds. | | `max_name_len` | `127` | The maximum allowed bytes, in UTF-8 format, for column and table names. | | `protocol_version` | | Explicitly specifies the version of InfluxDB Line Protocol to use for sender. Valid options are:
• protocol_version=1
• protocol_version=2
• protocol_version=3
• protocol_version=auto (default, if unspecified) | +### WebSocket / QWP-only parameters + +| Name | Default | Description | +| --------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------- | +| `request_durable_ack` | `off` | `on` / `off` — opts into per-table object-store ACK watermarks (cast to `IQwpWebSocketSender`). | +| `sf_dir` | | Path to a local directory enabling store-and-forward. Sets the SF stack on this sender. | +| `sender_id` | `default` | Slot identifier under `//`. Must be unique per process sharing the same `sf_dir`. | +| `sf_max_bytes` | `4194304` | Per-segment rotation threshold in bytes (default 4 MiB). | +| `sf_max_total_bytes` | `10 GiB` with `sf_dir`, `128 MiB` otherwise | Hard cap on total disk usage; back-pressures the producer when exceeded. | +| `sf_durability` | `memory` | Durability mode. Only `memory` is supported in v1. | +| `sf_append_deadline_millis` | `30000` | Max wait when the disk cap is hit before `Send` throws. | +| `reconnect_initial_backoff_millis`| `100` | Starting backoff for reconnect attempts. | +| `reconnect_max_backoff_millis` | `5000` | Cap on per-attempt backoff. | +| `reconnect_max_duration_millis` | `300000` | Total per-outage budget; sender becomes terminal if exceeded. | +| `initial_connect_retry` | `off` | `on` makes the first connect honour the same backoff loop. Default is "fail fast on first connect". | +| `close_flush_timeout_millis` | `60000` | Max wait at `Dispose` for the SF engine to drain (matches Java). `0` or `-1` for fast close. | +| `drain_orphans` | `off` | `on` adopts unlocked sibling slots on startup and drains them in the background. | +| `max_background_drainers` | `4` | Cap on concurrent orphan-drain workers. | + ### Protocol Version Behavior details: @@ -184,10 +323,11 @@ Behavior details: ### net-questdb-client specific parameters -| Name | Default | Description | -| -------------- | -------- | ------------------------------------------------------------------------------------- | -| `own_socket` | `true` | Specifies whether the internal TCP data stream will own the underlying socket or not. | -| `pool_timeout` | `120000` | Sets the idle timeout for HTTP connections in SocketsHttpHandler. | +| Name | Default | Description | +| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `own_socket` | `true` | Specifies whether the internal TCP data stream will own the underlying socket or not. | +| `pool_timeout` | `120000` | Sets the idle timeout for HTTP connections in SocketsHttpHandler. | +| `convert_local_to_utc` | `off` | When `on`, a `DateTime` whose `Kind` is `Local` is converted to UTC (via `ToUniversalTime()`) before its timestamp is encoded. `Utc` and `Unspecified` values, and all `DateTimeOffset` values, are written unchanged. Default `off` writes a local value's raw wall-clock ticks as-is (i.e. as though it were UTC). Applies to HTTP, TCP, and WS ingest. | ## Properties and methods @@ -243,7 +383,7 @@ Come visit the [QuestDB community Slack](https://slack.questdb.io). We welcome contributors to the project. Before you begin, a couple notes... - Prior to opening a pull request, please create an issue - to [discuss the scope of your proposal](https://github.com/questdb/c-questdb-client/issues). + to [discuss the scope of your proposal](https://github.com/questdb/net-questdb-client/issues). - Please write simple code and concise documentation, when appropriate. diff --git a/ci/azurre-binaries-pipeline.yml b/ci/azure-binaries-pipeline.yml similarity index 100% rename from ci/azurre-binaries-pipeline.yml rename to ci/azure-binaries-pipeline.yml diff --git a/ci/azure-pipelines.yml b/ci/azure-pipelines.yml index 5c894b78..6104818f 100644 --- a/ci/azure-pipelines.yml +++ b/ci/azure-pipelines.yml @@ -67,6 +67,39 @@ steps: version: '10.0.x' installationPath: $(Agent.ToolsDirectory)/dotnet +- task: JavaToolInstaller@0 + displayName: 'Install JDK 25 (to build QuestDB)' + inputs: + versionSpec: '25' + jdkArchitectureOption: 'x64' + jdkSourceOption: 'PreInstalled' + condition: eq(variables['osName'], 'Linux') + +- script: | + df -h / + sudo rm -rf /usr/local/lib/android /opt/ghc /usr/share/swift /opt/hostedtoolcache/CodeQL 2>/dev/null || true + df -h / + displayName: 'Free agent disk space (Linux)' + condition: eq(variables['osName'], 'Linux') + +- script: | + set -e + git clone --depth 1 https://github.com/questdb/questdb.git questdb-src + cd questdb-src + CLIENT_VERSION=$(sed -n 's/.*\(.*\)<\/questdb.client.version>.*/\1/p' core/pom.xml | head -1) + echo "questdb.client.version=$CLIENT_VERSION" + PROFILES=build-binaries + case "$CLIENT_VERSION" in + *-SNAPSHOT) + git submodule update --init --depth 1 java-questdb-client + PROFILES=build-binaries,local-client + ;; + esac + mvn -q -pl core -am -DskipTests -P "$PROFILES" package + echo "##vso[task.setvariable variable=QUESTDB_REPO]$(pwd)" + displayName: 'Clone + build QuestDB master' + condition: eq(variables['osName'], 'Linux') + - task: DotNetCoreCLI@2 displayName: 'Restore dependencies' inputs: @@ -80,24 +113,137 @@ steps: projects: 'net-questdb-client.sln' arguments: '--configuration $(buildConfiguration) --no-restore' +# Cross-repo trigger: fire the questdb-enterprise +# build-and-test-e2e-net-client pipeline with this build's SHA so +# the net_client-marked failover tests run against an Enterprise +# primary. The Enterprise pipeline posts a status check back on the +# PR (`enterprise-e2e-net-client` context); this step fire-and-forgets. +# +# Prerequisites (one-time, in the Azure DevOps UI): +# 1. Register ci/build-and-test-e2e-net-client.yaml in the +# `questdb-enterprise` ADO project as a pipeline named +# `build-and-test-e2e-net-client`. +# 2. Create a PAT in the `questdb-enterprise` ADO project with +# `Build (Read & execute)` scope. Add it as a secret variable +# on THIS pipeline named ENT_DISPATCH_PAT. +- bash: | + set -euo pipefail + + ORG_URL="$(System.TeamFoundationCollectionUri)" + PROJECT="questdb-enterprise" + PIPELINE_NAME="build-and-test-e2e-net-client" + + if [ -z "${ENT_DISPATCH_PAT:-}" ]; then + echo "ENT_DISPATCH_PAT not set; skipping enterprise e2e dispatch." + exit 0 + fi + + echo "Looking up '${PIPELINE_NAME}' in '${PROJECT}'..." + PIPELINES=$(curl -fsS -u ":${ENT_DISPATCH_PAT}" \ + "${ORG_URL}${PROJECT}/_apis/pipelines?api-version=7.0") + PIPELINE_ID=$(echo "$PIPELINES" | jq -r --arg n "$PIPELINE_NAME" \ + '.value[] | select(.name == $n) | .id' | head -1) + + if [ -z "$PIPELINE_ID" ] || [ "$PIPELINE_ID" = "null" ]; then + echo "Pipeline '${PIPELINE_NAME}' not found in '${PROJECT}'; skipping." + exit 0 + fi + + CLIENT_PR_NUMBER="$(System.PullRequest.PullRequestNumber)" + if [ -z "${CLIENT_PR_NUMBER}" ] || [ "${CLIENT_PR_NUMBER}" = "None" ]; then + CLIENT_PR_NUMBER="" + fi + + if [ "$(Build.Reason)" = "PullRequest" ]; then + CLIENT_BRANCH="$(System.PullRequest.SourceBranch)" + CLIENT_BRANCH="${CLIENT_BRANCH#refs/heads/}" + else + CLIENT_BRANCH="$(Build.SourceBranchName)" + fi + + BODY=$(jq -n \ + --arg commit "$(Build.SourceVersion)" \ + --arg pr "${CLIENT_PR_NUMBER}" \ + --arg branch "${CLIENT_BRANCH}" \ + '{ + templateParameters: { + netClientCommit: $commit, + netClientPrNumber: $pr, + clientBranch: $branch + }, + resources: { repositories: { self: { refName: "refs/heads/main" } } } + }') + + echo "Dispatching enterprise e2e: commit=$(Build.SourceVersion) PR=${CLIENT_PR_NUMBER} branch=${CLIENT_BRANCH}" + + RESPONSE=$(curl -fsS -u ":${ENT_DISPATCH_PAT}" \ + -H "Content-Type: application/json" \ + -X POST \ + -d "$BODY" \ + "${ORG_URL}${PROJECT}/_apis/pipelines/${PIPELINE_ID}/runs?api-version=7.0") + + RUN_URL=$(echo "$RESPONSE" | jq -r '._links.web.href // ""') + echo "Enterprise build queued: ${RUN_URL}" + displayName: 'Dispatch Enterprise e2e pipeline' + condition: and(succeeded(), eq(variables['osName'], 'Linux')) + env: + ENT_DISPATCH_PAT: $(ENT_DISPATCH_PAT) + - task: DotNetCoreCLI@2 - displayName: 'Run all tests on $(osName)' + displayName: 'Run all tests on $(osName) (net8.0)' inputs: command: 'test' projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj' - arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage"' + arguments: '--configuration $(buildConfiguration) --framework net8.0 --no-build --verbosity normal --logger trx --blame-hang --blame-hang-timeout 600s --blame-hang-dump-type mini --filter "FullyQualifiedName!~QuestDbIntegrationTests&FullyQualifiedName!~QuestDbWebSocketIntegrationTests&FullyQualifiedName!~QuestDbQueryIntegrationTests"' publishTestResults: true + +- task: DotNetCoreCLI@2 + displayName: 'Run all tests on $(osName) (net9.0, with coverage)' + inputs: + command: 'test' + projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj' + arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --blame-hang --blame-hang-timeout 600s --blame-hang-dump-type mini --collect:"XPlat Code Coverage"' + publishTestResults: true + env: + QUESTDB_REPO: $(QUESTDB_REPO) condition: eq(variables['osName'], 'Linux') - task: DotNetCoreCLI@2 - displayName: 'Run tests on $(osName) (excluding integration tests)' + displayName: 'Run tests on $(osName) (net9.0, excluding integration tests)' inputs: command: 'test' projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj' - arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --filter "FullyQualifiedName!~QuestDbIntegrationTests"' + arguments: '--configuration $(buildConfiguration) --framework net9.0 --no-build --verbosity normal --logger trx --blame-hang --blame-hang-timeout 600s --blame-hang-dump-type mini --collect:"XPlat Code Coverage" --filter "FullyQualifiedName!~QuestDbIntegrationTests&FullyQualifiedName!~QuestDbWebSocketIntegrationTests&FullyQualifiedName!~QuestDbQueryIntegrationTests"' publishTestResults: true condition: ne(variables['osName'], 'Linux') +- task: DotNetCoreCLI@2 + displayName: 'Run all tests on $(osName) (net10.0)' + inputs: + command: 'test' + projects: 'src/net-questdb-client-tests/net-questdb-client-tests.csproj' + arguments: '--configuration $(buildConfiguration) --framework net10.0 --no-build --verbosity normal --logger trx --blame-hang --blame-hang-timeout 600s --blame-hang-dump-type mini --filter "FullyQualifiedName!~QuestDbIntegrationTests&FullyQualifiedName!~QuestDbWebSocketIntegrationTests&FullyQualifiedName!~QuestDbQueryIntegrationTests"' + publishTestResults: true + +- task: CopyFiles@2 + displayName: 'Stage hang dumps + trx ($(osName))' + condition: always() + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: | + **/*.dmp + **/*Sequence*.xml + **/*.trx + targetFolder: '$(Build.ArtifactStagingDirectory)/TestArtifacts' + +- task: PublishPipelineArtifact@1 + displayName: 'Publish hang dumps + trx ($(osName))' + condition: always() + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/TestArtifacts' + artifact: 'TestArtifacts-$(osName)-$(Build.BuildId)' + publishLocation: 'pipeline' + - task: PublishCodeCoverageResults@2 displayName: 'Publish code coverage' inputs: diff --git a/docs/qwp-benchmarks.md b/docs/qwp-benchmarks.md new file mode 100644 index 00000000..8ac201c9 --- /dev/null +++ b/docs/qwp-benchmarks.md @@ -0,0 +1,174 @@ +# .NET WebSocket / QWP — Performance Benchmarks + +## Environment + +- **Server**: QuestDB master build with `/write/v4` (ingest) and `/read/v1` (egress) enabled, on `127.0.0.1:9000` +- **Host**: Apple M4 Pro, 14 logical / 14 physical cores, macOS 15.2 +- **Runtime**: .NET 10.0.7, Arm64 RyuJIT AdvSIMD +- **BenchmarkDotNet**: v0.13.12, in-process toolchain + +## TL;DR + +- **Ingest throughput** (`BenchInsertsWs`): WS beats HTTP **2.1–6.8×** across narrow / wide / multi-table at every AutoFlushRows setting. +- **Ingest latency** (`BenchLatencyWs`): WS full round-trip on par with HTTP (single-row median **85 vs 93 μs**); bare-handover `SendAsync` returns in **~5–10 μs** — ~10× faster caller-return. +- **Egress reads** (`BenchQueryWs`): WS reads **3–7× faster** than HTTP `/exec` across narrow and wide; narrow peak **38 M rows/sec** at 10M rows. +- **Egress query latency** (`BenchQueryLatencyWs`): WS bind-query median **97 μs** vs HTTP **114 μs** (p95 0.73×); single-row latency tied; WS allocates ~0 per query vs HTTP ~10 KB. +- **Store-and-forward overhead** (`BenchSfThroughput`): SF ingest runs **~2.3× slower** than non-SF (steady-state, 200k–500k rows) — the mmap durable-append cost of crash-survival — and is markedly higher-variance. + +## Methodology + +- All benches run in-process; `QDB_BENCH_ENDPOINT=127.0.0.1:9000` points at the live QuestDB server. `BenchInsertsWs`, `BenchLatencyWs`, and `BenchSfThroughput` fall back to an in-process `DummyQwpServer` + `DummyHttpServer` when it is unset; the egress benches (`BenchQueryWs`, `BenchQueryLatencyWs`) require a live server and have no dummy fallback. +- `BenchInsertsWs`, `BenchSfThroughput`, and `BenchQueryWs` run 20 iterations × 5 warmup, one shot per invocation (`InvocationCount=1`). +- Latency benches (`BenchLatencyWs`, `BenchQueryLatencyWs`) default to 20 000 iterations with `InvocationCount=1` (every iteration is one full round-trip), so percentile columns reflect that many samples; pass `--iterationCount 100000` for a stable p99 / p99.9 tail. +- All senders are created in `[GlobalSetup]` so per-invocation cost is row-encoding + frame I/O + ACK wait, not slot/mmap/engine spin-up. + +## 1. `BenchInsertsWs` — Sustained throughput + +**Workload**: long-lived sender, append N rows, `SendAsync()`. Three row shapes (Narrow / Wide / MultiTable) × AutoFlushRows {100, 1000, 10000} × Rows {10k, 100k}, each with an HTTP baseline. 20 iterations × 5 warmup. + +### At `Rows=100000` + +| Category | AFR | Mean WS | Mean HTTP | WS rows/sec | WS Ratio | +|---|---|---|---|---|---| +| **Narrow** | 100 | 15.75 ms | 92.53 ms | 6.35 M | **0.17 (5.9×)** | +| **Narrow** | 1000 | 13.44 ms | 36.96 ms | 7.44 M | **0.36 (2.7×)** | +| **Narrow** | 10000 | 13.30 ms | 32.03 ms | 7.52 M | **0.42 (2.4×)** | +| **Wide** | 100 | 52.66 ms | 161.96 ms | 1.90 M | **0.33 (3.1×)** | +| **Wide** | 1000 | 49.86 ms | 109.06 ms | 2.01 M | **0.46 (2.2×)** | +| **Wide** | 10000 | 48.68 ms | 100.45 ms | 2.05 M | **0.48 (2.1×)** | +| **MultiTable** | 100 | 14.21 ms | 96.73 ms | 7.04 M | **0.15 (6.8×)** | +| **MultiTable** | 1000 | 7.91 ms | 28.48 ms | 12.65 M | **0.28 (3.6×)** | +| **MultiTable** | 10000 | 7.21 ms | 20.72 ms | 13.87 M | **0.35 (2.9×)** | + +### Observations + +- **WS beats HTTP in every cell** — from 2.1× (Wide, high AFR) to 6.8× (MultiTable, AFR=100). The `Rows=10000` cases show the same ordering (WS 2.1–5.3× across shapes). +- **AutoFlushRows trade-off**: AFR=100 gives the biggest WS advantage — frequent small flushes amplify HTTP's per-request overhead, which WS pipelines away. Higher AFR raises absolute throughput but narrows the WS/HTTP ratio. +- **MultiTable peaks at ~13.9 M rows/sec** — WS multiplexes 5 tables over one connection with no per-flush handshake; the WS advantage holds a consistent 2.9–6.8× across all AFR settings. +- **Wide rows ~2 M rows/sec** — payload-bound; the WS/HTTP ratio is smaller (2.1–3.1×) because per-row encoding dominates over protocol overhead. +- **WS allocates far less managed memory than HTTP** — at `Rows=100000`, WS allocation is **< 0.02×** of HTTP for Narrow and Wide rows and **~0.2×** for MultiTable. HTTP rebuilds a full ILP text payload per flush (~20 MB narrow, ~85 MB wide); WS reuses encoder and segment-ring buffers — KB-scale for Narrow/Wide, ~3 MB for MultiTable's five interleaved per-table buffers. + +## 2. `BenchLatencyWs` — Single-batch send latency + +**Workload**: persistent senders, append `RowsPerBatch` rows, flush. Two categories, each with its own baseline: + +- **RoundTrip** — `SendAsync` + `PingAsync` (drains the in-flight ACK window), so the measured time is a full send + server-process + ACK round-trip. HTTP baseline. +- **Handover** — bare `SendAsync`, which returns after the async enqueue into the cursor send engine without waiting for the server. `Ws_HandoverRam` (RAM segment ring) is the baseline; `Ws_HandoverSf` adds an `sf_dir` mmap-backed ring. + +20 000 iterations per case (each iteration is one flush). + +| Category | RowsPerBatch | Method | Median | p95 | Min | Ratio | Allocated | +|---|---|---|---|---|---|---|---| +| **RoundTrip** | 1 | Http_Roundtrip | 118.0 μs | 221.4 μs | 40.4 μs | 1.00 | 9.3 KB | +| **RoundTrip** | 1 | **Ws_SyncRoundtrip** | **107.6 μs** | **178.7 μs** | 37.0 μs | **0.99** | **0** | +| **RoundTrip** | 100 | Http_Roundtrip | 139.4 μs | 208.2 μs | 63.8 μs | 1.00 | 21.2 KB | +| **RoundTrip** | 100 | **Ws_SyncRoundtrip** | **101.9 μs** | **144.2 μs** | 39.6 μs | **0.74** | **0** | +| **Handover** | 1 | Ws_HandoverRam | 1.0 μs | 3.5 μs | 0.6 μs | 1.00 | 0 | +| **Handover** | 1 | Ws_HandoverSf | 1.0 μs | 6.9 μs | 0.6 μs | 1.82\* | 0 | +| **Handover** | 100 | Ws_HandoverRam | 7.7 μs | 9.3 μs | 6.3 μs | 1.00 | 0 | +| **Handover** | 100 | Ws_HandoverSf | 10.4 μs | 55.2 μs | 6.5 μs | 2.51\* | 0 | + +### Observations + +- **WS full round-trip is on par with HTTP** — single-row median 107.6 vs 118.0 μs, 100-row 101.9 vs 139.4 μs. The Mean-based ratios (0.99 / 0.74) carry large RatioSD (0.43 / 0.22), so read this as *comparable* — WS is not slower, with an edge at the 100-row batch. +- **Handover returns ~15–100× faster than the round-trip** — bare `SendAsync` returns in ~1–8 μs median because the caller hands off to the cursor send engine and does not wait for the server ACK. +- **Store-and-forward's latency cost is in the tail, not the median** — `Ws_HandoverSf` median is within ~3 μs of RAM, but its p95 (7–55 μs) is ~2–6× the RAM p95 (3–9 μs): mmap page faults / flush cadence show up as tail spikes. \* The Mean-based SF ratio (1.8× / 2.5×, RatioSD up to 2.65) is tail-inflated and noisy — it is **not** "SF 2.5× slower"; on the median SF adds only ~0–3 μs. +- **Zero allocation on every WS path** — `Ws_SyncRoundtrip` and both Handover methods allocate ~0 (pooled encoder buffers, persistent connection); HTTP allocates 9–21 KB per round-trip. +- Sub-100 μs single-shot latency on loopback is inherently noisy — median and p95 are the deliverable, not Mean. + +Pass `--iterationCount 100000` for a stable p99 / p99.9. + +## 3. `BenchQueryWs` — Egress (read) throughput + +**Workload**: persistent `QwpQueryClient`, `SELECT FROM table LIMIT N` against pre-seeded tables (10 M rows each). Schema matches the server egress fixtures — Narrow = 5 columns (`ts, id, price, sym, note`), Wide = 15 columns (`ts, id, price, sym, note, d1–d5, s1–s5`). HTTP `/exec` baseline streams the JSON `dataset[][]` and extracts every cell value so both methods do equivalent decode work. 20 iterations × 5 warmup. + +| Category | RowCount | Mean WS | Mean HTTP | WS rows/sec | WS Ratio | Alloc Ratio | +|---|---|---|---|---|---|---| +| **Narrow** | 10 k | 1.43 ms | 4.61 ms | 7.01 M | **0.31 (3.2×)** | ~0 | +| **Narrow** | 100 k | 4.13 ms | 23.18 ms | 24.21 M | **0.18 (5.6×)** | ~0 | +| **Narrow** | 1 M | 27.20 ms | 180.11 ms | 36.76 M | **0.15 (6.7×)** | 0.29 | +| **Narrow** | 10 M | 262.83 ms | 1516.02 ms | **38.05 M** | **0.17 (5.9×)** | 1.05 | +| **Wide** | 10 k | 3.30 ms | 9.30 ms | 3.03 M | **0.36 (2.8×)** | ~0 | +| **Wide** | 100 k | 45.03 ms | 51.66 ms | 2.22 M | **0.87 (1.15×)** | 0.19 | +| **Wide** | 1 M | 119.57 ms | 449.55 ms | 8.36 M | **0.27 (3.7×)** | 0.57 | +| **Wide** | 10 M | 694.96 ms | 4438.37 ms | 14.39 M | **0.16 (6.3×)** | 1.57 | + +### Observations + +- **WS reads 3–7× faster than HTTP `/exec`** at most sizes, peaking at **38 M rows/sec** (narrow, 10 M). The advantage grows with result size as the QWP columnar decode amortises fixed per-query cost; HTTP `/exec` stays JSON-parse-bound at ~2–7 M rows/sec. +- **Wide at 100 k is the one soft spot** — WS 45 ms vs HTTP 52 ms, only 1.15×. The WS figure there is anomalously slow for its size (2.2 M rows/sec, against 8.4 M at 1 M rows) with ~10 % StdDev — treat it as a noisy cell, not a real plateau; a re-run is worthwhile. +- **Egress decode allocation is modest for both paths** — WS allocates ~0 at small result sets (pooled column scratches) and lands roughly on par with HTTP at 10 M rows (~170 KB narrow; WS ~450 KB vs HTTP ~290 KB wide, as the column scratches grow with the result). Neither path allocates per row. + +## 4. `BenchQueryLatencyWs` — Egress (read) query latency + +**Workload**: persistent `QwpQueryClient`, one query per iteration (each iteration is one full query round-trip — IterationCount is the percentile sample size). This run used `--iterationCount 100000`, above the 20 000 default, for a stable tail. Two categories: **SingleRow** (`SELECT id` from a 1-row table) and **Bind** (`SELECT x FROM long_sequence(10) WHERE x = $1` — QWP bind parameter vs HTTP value inlined into the SQL text). HTTP `/exec` baseline. + +| Category | Method | Median | p90 | p95 | Min | Ratio | +|---|---|---|---|---|---|---| +| **SingleRow** | Http_SelectSingleRow | 101 μs | 135 μs | 150 μs | 39 μs | 1.00 | +| **SingleRow** | **Ws_SelectSingleRow** | **101 μs** | **126 μs** | **135 μs** | 50 μs | 1.05\* | +| **Bind** | Http_SelectWhereBind | 114 μs | 187 μs | 214 μs | 41 μs | 1.00 | +| **Bind** | **Ws_SelectWhereBind** | **97 μs** | **141 μs** | **157 μs** | 32 μs | **0.89** | + +### Observations + +- **SingleRow**: WS and HTTP tie on the median (101 μs). The Mean ratio (1.05) carries RatioSD 0.29 — within noise; read this as *indistinguishable*, not "WS slower". WS is slightly better at the tail (p90 / p95). +- **Bind**: WS is faster at every percentile — median 97 vs 114 μs, p95 157 vs 214 μs (0.73× tail). +- **Allocation**: HTTP allocates ~10 KB per query (response string + `HttpClient` machinery); WS allocates ~0 (pooled decoder scratches, persistent connection). +- High sample variance is inherent to sub-100 μs single-shot latency on loopback (GC, socket, scheduling) — the percentile columns are the deliverable, not the Mean. The BDN `MinIterationTime ... very small` warning is expected for a latency benchmark and is ignorable. + +\* RatioSD 0.29 — not statistically distinguishable from 1.00. + +## 5. `BenchSfThroughput` — Store-and-forward overhead + +**Workload**: long-lived senders, one with `sf_dir` (mmap-backed segment ring), one without (RAM-backed). Each iteration appends N rows, `SendAsync()`, then `Ping()` to wait for cumulative ACK — symmetric across both. 20 iterations × 5 warmup. + +SF-vs-non-SF ratio, across repeated runs (latest run's absolute means shown): + +| Rows | SF Ratio (per run) | WS_NoSf | WS_WithSf | Allocated NoSf / WithSf | +|---|---|---|---|---| +| 200000 | 2.43× / 2.52× / 1.90× / 2.26× | 25.7 ms | 58.1 ms | 264 KB / 195 KB | +| 500000 | 2.35× / 2.30× / 2.37× | 63.5 ms | 149.9 ms | 630 KB / 408 KB | + +### Observations + +- **SF ingest steady-state overhead is ~2.3×** — the 500k cell is the most reliable read (three runs at 2.30× / 2.35× / 2.37×, non-SF baseline StdDev ~2%). Every frame is appended to an mmap-backed segment file before it ships; that durable append is what lets frames replay after a process or host crash. +- **The non-SF baseline is stable once the workload is ≥ 200k rows** — StdDev ~2%. Smaller workloads (10k–100k) were dropped from the param set because their baselines were dominated by run-to-run outliers. +- **SF is markedly higher-variance than non-SF** — `Ws_WithSf` StdDev runs 8–22% of its mean (vs ~2% for non-SF), and its p95 sits well above the mean. mmap flush cadence and page faults produce irregular tail latency; this is why a single run's SF ratio can land anywhere in ~1.9–2.5×. +- **SF adds no managed-memory pressure** — `Ws_WithSf` allocates *less* managed memory than non-SF (~0.65–0.74×). Frame bytes live in off-heap mmap / native segments either way; the managed allocation measured here is per-frame bookkeeping. +- This is the steady-state happy-path cost. SF's actual payoff — replay through a server outage or process restart — is not exercised here. + +## Caveats + +1. **Local loopback only** — TCP/WebSocket handshake on `127.0.0.1` is much faster than network round-trips. Real-network numbers will be higher in absolute terms; relative ratios should hold. +2. **Single-host** — server and client share CPU and memory. Absolute throughput drifts noticeably between runs, especially for shorter workloads. Compare WS-vs-HTTP / SF-vs-non-SF *within* a run, and prefer the larger workloads for any cross-run comparison. +3. **SF bench is happy-path ingest only** — the product justification for SF (reconnect + replay through a server outage) is not exercised here. A transient-failure benchmark is separately needed. + +## Reproduction + +```fish +# Ingest throughput / SF +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchInsertsWs*' '*BenchSfThroughput*' + +# Egress reads (live QuestDB master) +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchQueryWs*' + +# Egress query latency (live QuestDB master) +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchQueryLatencyWs*' + +# Latency, strict p99 (100k samples) +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchLatencyWs*' --iterationCount 100000 + +# Latency, quick smoke (1k samples) +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchLatencyWs*' --iterationCount 1000 +``` diff --git a/examples.manifest.yaml b/examples.manifest.yaml index 7351a008..27b1a3ba 100644 --- a/examples.manifest.yaml +++ b/examples.manifest.yaml @@ -22,3 +22,24 @@ header: |- [.NET client library](https://github.com/questdb/net-questdb-client) conf: http::addr=localhost:9000; + +- name: qwp-ingest + lang: csharp + path: src/example-qwp-ingest/Program.cs + header: |- + [.NET client library](https://github.com/questdb/net-questdb-client) + conf: ws::addr=localhost:9000; + +- name: qwp-ingest-auth-tls + lang: csharp + path: src/example-qwp-ingest-auth-tls/Program.cs + header: |- + [.NET client library](https://github.com/questdb/net-questdb-client) + conf: wss::addr=localhost:9000;username=admin;password=quest;tls_verify=unsafe_off; + +- name: qwp-query + lang: csharp + path: src/example-qwp-query/Program.cs + header: |- + [.NET client library](https://github.com/questdb/net-questdb-client) + conf: ws::addr=localhost:9000;target=any; diff --git a/net-questdb-client.sln b/net-questdb-client.sln index 91cec484..43fa637d 100644 --- a/net-questdb-client.sln +++ b/net-questdb-client.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.30114.105 @@ -21,50 +21,174 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "example-auth-http-tls", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "example-aot", "src\example-aot\example-aot.csproj", "{5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "example-qwp-ingest", "src\example-qwp-ingest\example-qwp-ingest.csproj", "{A1B2C3D4-E5F6-4789-A012-345678901234}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "example-qwp-ingest-auth-tls", "src\example-qwp-ingest-auth-tls\example-qwp-ingest-auth-tls.csproj", "{A1FE95A9-4761-4806-8891-A82F468624F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "example-qwp-query", "src\example-qwp-query\example-qwp-query.csproj", "{A247ACD9-F600-47D3-B8C6-33543B4FB95B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test-apps", "test-apps", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {456B1860-0102-48D7-861A-5F9963F3887B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {456B1860-0102-48D7-861A-5F9963F3887B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Debug|x64.ActiveCfg = Debug|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Debug|x64.Build.0 = Debug|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Debug|x86.ActiveCfg = Debug|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Debug|x86.Build.0 = Debug|Any CPU {456B1860-0102-48D7-861A-5F9963F3887B}.Release|Any CPU.ActiveCfg = Release|Any CPU {456B1860-0102-48D7-861A-5F9963F3887B}.Release|Any CPU.Build.0 = Release|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Release|x64.ActiveCfg = Release|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Release|x64.Build.0 = Release|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Release|x86.ActiveCfg = Release|Any CPU + {456B1860-0102-48D7-861A-5F9963F3887B}.Release|x86.Build.0 = Release|Any CPU {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Debug|x64.Build.0 = Debug|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Debug|x86.Build.0 = Debug|Any CPU {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Release|Any CPU.ActiveCfg = Release|Any CPU {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Release|Any CPU.Build.0 = Release|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Release|x64.ActiveCfg = Release|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Release|x64.Build.0 = Release|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Release|x86.ActiveCfg = Release|Any CPU + {121EAA4D-3A73-468C-8CAB-A2A4BEF848CF}.Release|x86.Build.0 = Release|Any CPU {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Debug|x64.Build.0 = Debug|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Debug|x86.Build.0 = Debug|Any CPU {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Release|Any CPU.Build.0 = Release|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Release|x64.ActiveCfg = Release|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Release|x64.Build.0 = Release|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Release|x86.ActiveCfg = Release|Any CPU + {FBB8181C-6BAB-46C2-A47A-D3566A3997FE}.Release|x86.Build.0 = Release|Any CPU {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Debug|x64.Build.0 = Debug|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Debug|x86.Build.0 = Debug|Any CPU {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Release|Any CPU.ActiveCfg = Release|Any CPU {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Release|Any CPU.Build.0 = Release|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Release|x64.ActiveCfg = Release|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Release|x64.Build.0 = Release|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Release|x86.ActiveCfg = Release|Any CPU + {7F3564FE-78CC-487A-BA1C-019D2BE07B02}.Release|x86.Build.0 = Release|Any CPU {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Debug|x64.Build.0 = Debug|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Debug|x86.Build.0 = Debug|Any CPU {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Release|Any CPU.ActiveCfg = Release|Any CPU {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Release|Any CPU.Build.0 = Release|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Release|x64.ActiveCfg = Release|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Release|x64.Build.0 = Release|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Release|x86.ActiveCfg = Release|Any CPU + {6C336385-5451-4AEF-9C8D-BA252BF0A1DE}.Release|x86.Build.0 = Release|Any CPU {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Debug|x64.ActiveCfg = Debug|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Debug|x64.Build.0 = Debug|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Debug|x86.ActiveCfg = Debug|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Debug|x86.Build.0 = Debug|Any CPU {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Release|Any CPU.ActiveCfg = Release|Any CPU {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Release|Any CPU.Build.0 = Release|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Release|x64.ActiveCfg = Release|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Release|x64.Build.0 = Release|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Release|x86.ActiveCfg = Release|Any CPU + {348E00C0-0ED0-4932-A0CE-EC80379E843A}.Release|x86.Build.0 = Release|Any CPU {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Debug|Any CPU.Build.0 = Debug|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Debug|x64.ActiveCfg = Debug|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Debug|x64.Build.0 = Debug|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Debug|x86.ActiveCfg = Debug|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Debug|x86.Build.0 = Debug|Any CPU {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Release|Any CPU.ActiveCfg = Release|Any CPU {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Release|Any CPU.Build.0 = Release|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Release|x64.ActiveCfg = Release|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Release|x64.Build.0 = Release|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Release|x86.ActiveCfg = Release|Any CPU + {467D00AD-EFEE-4915-AB09-FCA4EF589286}.Release|x86.Build.0 = Release|Any CPU {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Debug|x64.Build.0 = Debug|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Debug|x86.Build.0 = Debug|Any CPU {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Release|Any CPU.Build.0 = Release|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Release|x64.ActiveCfg = Release|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Release|x64.Build.0 = Release|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Release|x86.ActiveCfg = Release|Any CPU + {24D93DBB-3783-423F-81CC-6B9BFD33F6CD}.Release|x86.Build.0 = Release|Any CPU {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Debug|x64.Build.0 = Debug|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Debug|x86.Build.0 = Debug|Any CPU {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Release|Any CPU.ActiveCfg = Release|Any CPU {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Release|Any CPU.Build.0 = Release|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Release|x64.ActiveCfg = Release|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Release|x64.Build.0 = Release|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Release|x86.ActiveCfg = Release|Any CPU + {5341FCF0-F71D-4160-8D6E-B5EFDF92E9E8}.Release|x86.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4789-A012-345678901234}.Release|x86.Build.0 = Release|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Debug|x64.Build.0 = Debug|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Debug|x86.Build.0 = Debug|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Release|Any CPU.Build.0 = Release|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Release|x64.ActiveCfg = Release|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Release|x64.Build.0 = Release|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Release|x86.ActiveCfg = Release|Any CPU + {A1FE95A9-4761-4806-8891-A82F468624F8}.Release|x86.Build.0 = Release|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Debug|x64.ActiveCfg = Debug|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Debug|x64.Build.0 = Debug|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Debug|x86.ActiveCfg = Debug|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Debug|x86.Build.0 = Debug|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Release|Any CPU.Build.0 = Release|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Release|x64.ActiveCfg = Release|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Release|x64.Build.0 = Release|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Release|x86.ActiveCfg = Release|Any CPU + {A247ACD9-F600-47D3-B8C6-33543B4FB95B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A1FE95A9-4761-4806-8891-A82F468624F8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A247ACD9-F600-47D3-B8C6-33543B4FB95B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/dummy-http-server/DummyQwpServer.cs b/src/dummy-http-server/DummyQwpServer.cs new file mode 100644 index 00000000..1bb549c7 --- /dev/null +++ b/src/dummy-http-server/DummyQwpServer.cs @@ -0,0 +1,399 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Collections.Concurrent; +using System.Net; +using System.Net.WebSockets; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace dummy_http_server; + +/// +/// Lightweight Kestrel-based /write/v4 WebSocket endpoint for QWP transport tests. +/// +/// +/// Spins up on http://127.0.0.1:0 (a random ephemeral port). The bound URI is exposed +/// via after completes. A test-supplied +/// decides what response (if any) to send for +/// each incoming binary frame. +/// +/// Captures everything received in and exposes the upgrade +/// request headers via so tests can verify that the client +/// sent the expected X-QWP-* headers. +/// +public sealed class DummyQwpServer : IAsyncDisposable +{ + private readonly DummyQwpServerOptions _options; + private readonly IHost _host; + private readonly ConcurrentQueue _received = new(); + + private string? _baseUri; + + public DummyQwpServer(DummyQwpServerOptions? options = null) + { + _options = options ?? new DummyQwpServerOptions(); + + _host = new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost.UseKestrel(kestrel => + { + kestrel.Listen(IPAddress.Loopback, 0, listen => + { + listen.Protocols = HttpProtocols.Http1; + if (_options.TlsCertificate is not null) + { + listen.UseHttps(_options.TlsCertificate); + } + }); + }); + webHost.Configure(app => + { + app.UseWebSockets(); + app.Run(async ctx => + { + if (!ctx.Request.Path.Equals(_options.Path, StringComparison.Ordinal)) + { + ctx.Response.StatusCode = (int)HttpStatusCode.NotFound; + return; + } + + await HandleWriteV4(ctx).ConfigureAwait(false); + }); + }); + }) + .Build(); + } + + /// The URI clients should connect to. Available after returns. + public Uri Uri + { + get + { + if (_baseUri is null) + { + throw new InvalidOperationException("Server has not started yet"); + } + + var ws = _baseUri + .Replace("https://", "wss://", StringComparison.OrdinalIgnoreCase) + .Replace("http://", "ws://", StringComparison.OrdinalIgnoreCase); + return new Uri(ws + _options.Path); + } + } + + /// All binary frames received, in arrival order. + public IReadOnlyCollection ReceivedFrames => _received; + + /// Headers from the last WebSocket upgrade request, set after the upgrade completes. + public IDictionary? LastUpgradeHeaders { get; private set; } + + /// Count of WebSocket upgrades the server has accepted. Useful for asserting no reconnect. + public int UpgradeCount => Volatile.Read(ref _upgradeCount); + + private int _upgradeCount; + + /// Starts the host and binds to its random port. + public async Task StartAsync(CancellationToken ct = default) + { + await _host.StartAsync(ct).ConfigureAwait(false); + + var server = _host.Services.GetRequiredService(); + var addresses = server.Features.Get()?.Addresses + ?? throw new InvalidOperationException("server addresses unavailable"); + _baseUri = addresses.First(); + } + + /// + public async ValueTask DisposeAsync() + { + try + { + await _host.StopAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + catch + { + // best-effort + } + + _host.Dispose(); + await Task.CompletedTask; + } + + private async Task HandleWriteV4(HttpContext ctx) + { + if (!ctx.WebSockets.IsWebSocketRequest) + { + ctx.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await ctx.Response.WriteAsync("WebSocket upgrade required").ConfigureAwait(false); + return; + } + + // Capture the upgrade headers before the handshake runs. + LastUpgradeHeaders = ctx.Request.Headers + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString(), StringComparer.OrdinalIgnoreCase); + Interlocked.Increment(ref _upgradeCount); + + // The X-QWP-Version response header is set on Response.Headers BEFORE accepting; the + // accept call writes the 101 response. + if (_options.NegotiatedVersion is not null) + { + ctx.Response.Headers["X-QWP-Version"] = _options.NegotiatedVersion; + } + + if (_options.RejectUpgradeWith is { } rejectStatus) + { + ctx.Response.StatusCode = (int)rejectStatus; + if (_options.RejectUpgradeRoleHeader is { Length: > 0 } role) + { + ctx.Response.Headers["X-QuestDB-Role"] = role; + } + if (_options.RejectUpgradeZoneHeader is { Length: > 0 } zone) + { + ctx.Response.Headers["X-QuestDB-Zone"] = zone; + } + await ctx.Response.WriteAsync("rejected by test").ConfigureAwait(false); + return; + } + + if (_options.RoleHeader is { Length: > 0 } acceptRole) + { + ctx.Response.Headers["X-QuestDB-Role"] = acceptRole; + } + + if (_options.DurableAckEnabled) + { + ctx.Response.Headers["X-QWP-Durable-Ack"] = "enabled"; + } + + if (_options.MaxBatchSize is { } maxBatchSize) + { + ctx.Response.Headers["X-QWP-Max-Batch-Size"] = + maxBatchSize.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + using var ws = await ctx.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + + if (_options.InitialServerFrame is { Length: > 0 } initial) + { + await SendOutgoingAsync(ws, initial, ctx.RequestAborted).ConfigureAwait(false); + } + else if (_options.Path == "/read/v1") + { + // QWP egress contract: every server emits SERVER_INFO as the first frame after the + // upgrade. Auto-emit a minimal STANDALONE/no-zone/no-cluster-id frame so egress tests + // that don't care about server identity don't have to wire one up themselves. + await SendOutgoingAsync(ws, DefaultServerInfoFrame, ctx.RequestAborted).ConfigureAwait(false); + } + + var receiveBuf = new byte[_options.ReceiveBufferSize]; + var framesHandled = 0; + while (ws.State == WebSocketState.Open) + { + var totalRead = 0; + WebSocketReceiveResult result; + do + { + if (totalRead == receiveBuf.Length) + { + Array.Resize(ref receiveBuf, receiveBuf.Length * 2); + } + + result = await ws.ReceiveAsync( + new ArraySegment(receiveBuf, totalRead, receiveBuf.Length - totalRead), + ctx.RequestAborted) + .ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, ctx.RequestAborted) + .ConfigureAwait(false); + return; + } + + totalRead += result.Count; + } while (!result.EndOfMessage); + + var frame = new byte[totalRead]; + Buffer.BlockCopy(receiveBuf, 0, frame, 0, totalRead); + _received.Enqueue(frame); + + IReadOnlyList? multi = null; + if (_options.FrameHandlerMultiAsync is not null) + { + multi = await _options.FrameHandlerMultiAsync(frame).ConfigureAwait(false); + } + else + { + multi = _options.FrameHandlerMulti?.Invoke(frame); + } + + if (multi is not null) + { + foreach (var response in multi) + { + if (response is not null && response.Length > 0) + { + await SendOutgoingAsync(ws, response, ctx.RequestAborted).ConfigureAwait(false); + } + } + } + else + { + var response = _options.FrameHandler?.Invoke(frame); + if (response is not null && response.Length > 0) + { + await SendOutgoingAsync(ws, response, ctx.RequestAborted).ConfigureAwait(false); + } + } + + framesHandled++; + if (_options.CloseAfterFrameCount is { } cap && framesHandled >= cap) + { + await ws.CloseAsync(_options.CloseStatus, _options.CloseReason, ctx.RequestAborted) + .ConfigureAwait(false); + return; + } + } + } + + /// + /// Sends one QWP frame as a WebSocket binary message. When + /// is positive, the message is + /// split into WebSocket frames of at most that many bytes so the client's receive loop + /// must reassemble it across arbitrary boundaries. + /// + private static readonly byte[] DefaultServerInfoFrame = BuildDefaultServerInfoFrame(); + + private static byte[] BuildDefaultServerInfoFrame() + { + const int payloadLen = 26; + var frame = new byte[12 + payloadLen]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(0, 4), 0x31_50_57_51u); + frame[4] = 0x01; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(8, 4), payloadLen); + frame[12] = 0x18; + return frame; + } + + private async Task SendOutgoingAsync(WebSocket ws, byte[] payload, CancellationToken ct) + { + var chunk = _options.OutgoingFragmentSize; + if (chunk <= 0 || payload.Length <= chunk) + { + await ws.SendAsync(payload, WebSocketMessageType.Binary, endOfMessage: true, ct) + .ConfigureAwait(false); + return; + } + + for (var offset = 0; offset < payload.Length; offset += chunk) + { + var len = Math.Min(chunk, payload.Length - offset); + var endOfMessage = offset + len >= payload.Length; + await ws.SendAsync( + new ArraySegment(payload, offset, len), + WebSocketMessageType.Binary, endOfMessage, ct) + .ConfigureAwait(false); + } + } +} + +/// Configuration knobs for . +public sealed class DummyQwpServerOptions +{ + /// HTTP path to bind. Defaults to /write/v4. + public string Path { get; init; } = "/write/v4"; + + /// Value to return in the X-QWP-Version response header. Set to null to omit. + public string? NegotiatedVersion { get; init; } = "1"; + + /// If set, the server returns this HTTP status during the upgrade and never opens the WebSocket. + public HttpStatusCode? RejectUpgradeWith { get; init; } + + /// Optional X-QuestDB-Role header value attached to a rejection response (paired with 421 Misdirected Request to test role-aware failover). + public string? RejectUpgradeRoleHeader { get; init; } + + /// Optional X-QuestDB-Zone header value attached to a 421 rejection response. + public string? RejectUpgradeZoneHeader { get; init; } + + /// Optional X-QuestDB-Role header value attached to a successful 101 response (diagnostic / tests). + public string? RoleHeader { get; init; } + + /// If set, the server advertises this value in the X-QWP-Max-Batch-Size response header. + public int? MaxBatchSize { get; init; } + + /// If set, Kestrel binds an HTTPS listener using this certificate; returns a wss:// URI. + public X509Certificate2? TlsCertificate { get; init; } + + /// If true, the server echoes X-QWP-Durable-Ack: enabled on the upgrade response, + /// confirming durable-ack support to clients that requested it via request_durable_ack=on. + public bool DurableAckEnabled { get; init; } + + /// If set, the server emits its (optional) response then sends a WebSocket CLOSE with this status after handling the Nth frame (1-based). + public int? CloseAfterFrameCount { get; init; } + + /// Close status to send when triggers. + public WebSocketCloseStatus CloseStatus { get; init; } = WebSocketCloseStatus.InternalServerError; + + /// Optional close reason text accompanying . + public string? CloseReason { get; init; } = "test injected close"; + + /// + /// Frame the server sends to the client immediately after accepting the WebSocket and before + /// reading the first client frame. Use this to emit a v2 SERVER_INFO. + /// + public byte[]? InitialServerFrame { get; init; } + + /// Per-frame response generator. Return null/empty to suppress a response. + public Func? FrameHandler { get; init; } + + /// + /// Per-frame multi-response generator. Each call may return multiple response frames; the + /// server emits them one after another as separate WebSocket binary messages. + /// Use this when the response sequence has out-of-band frames (e.g. DURABLE_ACK + /// interleaved with the request's OK). + /// + public Func?>? FrameHandlerMulti { get; init; } + + public Func?>>? FrameHandlerMultiAsync { get; init; } + + /// Buffer size for reading incoming WebSocket messages. + public int ReceiveBufferSize { get; init; } = 64 * 1024; + + /// + /// If positive, every outgoing WebSocket message (the + /// and every response frame) is split into WebSocket frames of at most this many bytes, + /// forcing the client to reassemble it across arbitrary boundaries. Zero (the default) + /// sends each message as a single WebSocket frame. + /// + public int OutgoingFragmentSize { get; init; } +} diff --git a/src/example-qwp-ingest-auth-tls/Program.cs b/src/example-qwp-ingest-auth-tls/Program.cs new file mode 100644 index 00000000..6a854050 --- /dev/null +++ b/src/example-qwp-ingest-auth-tls/Program.cs @@ -0,0 +1,71 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using QuestDB; +using QuestDB.Senders; + +// Authenticated wss:// (WebSocket + TLS) ingest. The wire format is the same QWP columnar +// binary protocol as ws::, with TLS layered on. Authentication: Basic (username + password) +// or Bearer (token). The two are mutually exclusive — set one or the other. +// +// Connect-string knobs that matter for wss:: with auth: +// addr host:port (default 9000 for ws/wss) +// username/password Basic auth (mutually exclusive with token) +// token Bearer auth +// tls_verify on (default) or unsafe_off — disable cert validation only for testing +// tls_roots path to a custom .pem CA bundle / client cert +// tls_roots_password password / private-key file paired with tls_roots +// request_durable_ack on/off — opt in to per-table durable seqTxn watermarks +// +// In production: use tls_verify=on with the system trust store, or tls_roots pointing at a +// pinned CA. Below uses unsafe_off for self-signed dev / test setups; never ship that to prod. +await using var sender = + Sender.New( + "wss::addr=localhost:9000;username=admin;password=quest;tls_verify=unsafe_off;request_durable_ack=on;"); + +await sender.Table("trades") + .Symbol("symbol", "ETH-USD") + .Symbol("side", "sell") + .Column("price", 2615.54) + .Column("amount", 0.00044) + .AtAsync(DateTime.UtcNow); + +await sender.Table("trades") + .Symbol("symbol", "BTC-USD") + .Symbol("side", "buy") + .Column("price", 39269.98) + .Column("amount", 0.001) + .AtAsync(DateTime.UtcNow); + +await sender.SendAsync(); + +// Per-table durable / committed seqTxn watermarks are exposed via IQwpWebSocketSender. They +// require request_durable_ack=on for the durable watermark; the committed watermark works +// regardless. +if (sender is IQwpWebSocketSender ws) +{ + Console.WriteLine($"trades committed seqTxn: {ws.GetHighestAckedSeqTxn("trades")}"); + Console.WriteLine($"trades durable seqTxn: {ws.GetHighestDurableSeqTxn("trades")}"); +} diff --git a/src/example-qwp-ingest-auth-tls/example-qwp-ingest-auth-tls.csproj b/src/example-qwp-ingest-auth-tls/example-qwp-ingest-auth-tls.csproj new file mode 100644 index 00000000..91fe7886 --- /dev/null +++ b/src/example-qwp-ingest-auth-tls/example-qwp-ingest-auth-tls.csproj @@ -0,0 +1,19 @@ + + + + QuestDBDemo + enable + 10 + QuestDB client - WebSocket / QWP Example with Authentication and TLS + Authenticated wss:// WebSocket (QWP columnar binary) example using the QuestDB ILP client + net7.0;net8.0;net9.0;net10.0 + Exe + + false + + + + + + + diff --git a/src/example-qwp-ingest/Program.cs b/src/example-qwp-ingest/Program.cs new file mode 100644 index 00000000..70a2c9ef --- /dev/null +++ b/src/example-qwp-ingest/Program.cs @@ -0,0 +1,64 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using QuestDB; +using QuestDB.Senders; + +// Connect via the WebSocket / QWP transport. The wire format is a columnar binary +// protocol — much smaller and faster than the text ILP that http:: and tcp:: use. +// +// Connect-string knobs that matter for ws:: +// addr host:port (default port 9000, shared with HTTP) +// auto_flush_rows rows before an automatic flush is triggered (default 1000 for ws) +// auto_flush_interval milliseconds before an automatic flush (default 100 for ws) +// close_timeout ms to wait for in-flight ACKs on Dispose / Ping (default 5000) +// request_durable_ack on/off — opt in to per-table durable seqTxn watermarks +// username/password Basic auth, or +// token Bearer auth +await using var sender = Sender.New("ws::addr=localhost:9000;request_durable_ack=on;"); + +await sender.Table("trades") + .Symbol("symbol", "ETH-USD") + .Symbol("side", "sell") + .Column("price", 2615.54) + .Column("amount", 0.00044) + .AtAsync(DateTime.UtcNow); + +await sender.Table("trades") + .Symbol("symbol", "BTC-USD") + .Symbol("side", "buy") + .Column("price", 39269.98) + .Column("amount", 0.001) + .AtAsync(DateTime.UtcNow); + +await sender.SendAsync(); + +// When `request_durable_ack=on` is set, the WebSocket sender exposes per-table seqTxn watermarks +// via the IQwpWebSocketSender interface. +if (sender is IQwpWebSocketSender ws) +{ + Console.WriteLine($"trades committed seqTxn: {ws.GetHighestAckedSeqTxn("trades")}"); + Console.WriteLine($"trades durable seqTxn: {ws.GetHighestDurableSeqTxn("trades")}"); +} diff --git a/src/example-qwp-ingest/example-qwp-ingest.csproj b/src/example-qwp-ingest/example-qwp-ingest.csproj new file mode 100644 index 00000000..1931d0fa --- /dev/null +++ b/src/example-qwp-ingest/example-qwp-ingest.csproj @@ -0,0 +1,19 @@ + + + + QuestDBDemo + enable + 10 + QuestDB client - WebSocket / QWP Example + WebSocket (QWP columnar binary) example using the QuestDB ILP client + net7.0;net8.0;net9.0;net10.0 + Exe + + false + + + + + + + diff --git a/src/example-qwp-query/Program.cs b/src/example-qwp-query/Program.cs new file mode 100644 index 00000000..eb009913 --- /dev/null +++ b/src/example-qwp-query/Program.cs @@ -0,0 +1,107 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp.Query; +using System; +using System.Threading.Tasks; + +var example = args.Length > 0 ? args[0].ToLowerInvariant() : "basic"; +var connStr = Environment.GetEnvironmentVariable("QDB_QUERY") + ?? "ws::addr=localhost:9000;target=any;"; + +switch (example) +{ + case "basic": + await RunBasic(connStr); + break; + case "binds": + await RunWithBinds(connStr); + break; + case "errors": + await RunErrorHandling(connStr); + break; + default: + Console.Error.WriteLine($"unknown example: {example}"); + Console.Error.WriteLine("usage: example-qwp-query [basic|binds|errors]"); + Environment.Exit(2); + return; +} + +static async Task RunBasic(string connStr) +{ + using var client = QueryClient.New(connStr); + var handler = new PrintingHandler(); + await client.ExecuteAsync("SELECT 1 AS one, 'hello' AS greeting", handler); +} + +static async Task RunWithBinds(string connStr) +{ + using var client = QueryClient.New(connStr); + var handler = new PrintingHandler(); + QwpBindSetter binds = b => + { + b.SetLong(0, 42L); + b.SetVarchar(1, "hello"); + }; + await client.ExecuteAsync("SELECT $1 AS num, $2 AS s", binds, handler); +} + +static async Task RunErrorHandling(string connStr) +{ + using var client = QueryClient.New(connStr); + var handler = new PrintingHandler(); + await client.ExecuteAsync("SELECT * FROM no_such_table_does_it", handler); +} + +internal sealed class PrintingHandler : QwpColumnBatchHandler +{ + public override void OnBatch(QwpColumnBatch batch) + { + Console.WriteLine($"-- batch_seq={batch.BatchSeq} rows={batch.RowCount} cols={batch.ColumnCount} --"); + for (var c = 0; c < batch.ColumnCount; c++) + { + Console.Write($"{batch.GetColumnName(c)}({batch.GetColumnWireType(c)})\t"); + } + Console.WriteLine(); + for (var r = 0; r < batch.RowCount; r++) + { + for (var c = 0; c < batch.ColumnCount; c++) + { + Console.Write(batch.IsNull(c, r) ? "" : batch.GetString(c, r) ?? ""); + Console.Write('\t'); + } + Console.WriteLine(); + } + } + + public override void OnEnd(long totalRows) => Console.WriteLine($"-- end (totalRows={totalRows}) --"); + public override void OnError(QwpStatusCode status, string message) => + Console.Error.WriteLine($"-- error {status} (0x{(byte)status:X2}): {message} --"); + public override void OnExecDone(QwpOpType opType, long rowsAffected) => + Console.WriteLine($"-- exec_done op={opType} rows={rowsAffected} --"); + public override void OnFailoverReset(QwpServerInfo? newNode) => + Console.WriteLine($"-- failover reset (new node: {newNode?.NodeId ?? ""}) --"); +} diff --git a/src/example-qwp-query/example-qwp-query.csproj b/src/example-qwp-query/example-qwp-query.csproj new file mode 100644 index 00000000..2611b5e6 --- /dev/null +++ b/src/example-qwp-query/example-qwp-query.csproj @@ -0,0 +1,19 @@ + + + + QuestDBDemo + enable + 10 + QuestDB client - QWP Egress (Query) Example + Read-side WebSocket (QWP) example using the QuestDB query client + net7.0;net8.0;net9.0;net10.0 + Exe + + false + + + + + + + diff --git a/src/net-questdb-client-benchmarks/BenchAllocationsWs.cs b/src/net-questdb-client-benchmarks/BenchAllocationsWs.cs new file mode 100644 index 00000000..9855bf75 --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchAllocationsWs.cs @@ -0,0 +1,133 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Net.Sockets; +using BenchmarkDotNet.Attributes; +using QuestDB; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_benchmarks; + +/// +/// Allocation regression gate for the WS / QWP sender hot path. +/// reports per-op allocations; CI gating logic should diff against a stored baseline. Three row +/// shapes cover the typical type mix; sender lives across iterations so handshake cost is +/// amortised and only the per-row + per-batch alloc shows up. +/// +[MemoryDiagnoser] +public class BenchAllocationsWs +{ + private DummyQwpServer? _qwpServer; + private string _wsEndpoint = null!; + private ISender _wsSender = null!; + private long _rowSeq; + + [Params(100, 1000)] + public int RowsPerOp; + + [GlobalSetup] + public async Task Setup() + { + long ackSeq = 0; + _qwpServer = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref ackSeq) - 1; + var ack = new byte[9]; + ack[0] = 0x00; + BinaryPrimitives.WriteInt64LittleEndian(ack.AsSpan(1, 8), seq); + return ack; + }, + }); + await _qwpServer.StartAsync(); + _wsEndpoint = $"127.0.0.1:{_qwpServer.Uri.Port}"; + + _wsSender = Sender.New( + $"ws::addr={_wsEndpoint};auto_flush=off;"); + } + + [GlobalCleanup] + public async Task Cleanup() + { + _wsSender?.Dispose(); + if (_qwpServer is not null) await _qwpServer.DisposeAsync(); + } + + [Benchmark] + public async Task NarrowRows() + { + for (var i = 0; i < RowsPerOp; i++) + { + _wsSender + .Table("trades") + .Symbol("symbol", "ETH-USD") + .Column("price", 2615.54) + .Column("amount", 0.00044); + await _wsSender.AtAsync(DateTime.UtcNow); + } + await _wsSender.SendAsync(); + } + + [Benchmark] + public async Task WideRows() + { + for (var i = 0; i < RowsPerOp; i++) + { + _wsSender + .Table("wide") + .Symbol("g", "GROUP_A") + .Symbol("k", "KEY_X") + .Column("counter", _rowSeq++ * 1.0) + .Column("int_a", (long)i) + .Column("int_b", (long)(i * 2)) + .Column("flag", i % 2 == 0) + .Column("text", "hello"); + await _wsSender.AtAsync(DateTime.UtcNow); + } + await _wsSender.SendAsync(); + } + + [Benchmark] + public async Task SymbolHeavy() + { + for (var i = 0; i < RowsPerOp; i++) + { + _wsSender + .Table("sym_heavy") + .Symbol("a", "VAL_" + (i % 16)) + .Symbol("b", "VAL_" + (i % 8)) + .Symbol("c", "VAL_" + (i % 4)) + .Column("value", (long)i); + await _wsSender.AtAsync(DateTime.UtcNow); + } + await _wsSender.SendAsync(); + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchInsertsWs.cs b/src/net-questdb-client-benchmarks/BenchInsertsWs.cs new file mode 100644 index 00000000..152c3087 --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchInsertsWs.cs @@ -0,0 +1,230 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Order; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using QuestDB; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_benchmarks; + +/// +/// Sustained throughput for the WebSocket / QWP sender with HTTP baselines per row shape. +/// Senders live across iterations so handshake cost is amortised, not folded into the +/// per-iteration time. Each row shape (Narrow / Wide / MultiTable) has matched HTTP and WS +/// methods grouped by so BDN computes pairwise +/// ratios within the same workload. +/// +[Config(typeof(IngestThroughputConfig))] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory, BenchmarkLogicalGroupRule.ByParams)] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class BenchInsertsWs +{ + private DummyQwpServer? _qwpServer; + private DummyHttpServer? _httpServer; + private string _httpEndpoint = null!; + private string _wsEndpoint = null!; + + private ISender _httpSender = null!; + private ISender _wsSender = null!; + private string[] _wideStringG = null!; + private string[] _wideStringK = null!; + private long _rowSeq; + + [Params(100, 1000, 10000)] + public int AutoFlushRows; + + [Params(10_000, 100_000)] + public int Rows; + + [GlobalSetup] + public async Task Setup() + { + var realEndpoint = Environment.GetEnvironmentVariable("QDB_BENCH_ENDPOINT"); + if (!string.IsNullOrEmpty(realEndpoint)) + { + _httpEndpoint = realEndpoint; + _wsEndpoint = realEndpoint; + } + else + { + long ackSeq = 0; + _qwpServer = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref ackSeq) - 1; + var ack = new byte[9]; + ack[0] = 0x00; + BinaryPrimitives.WriteInt64LittleEndian(ack.AsSpan(1, 8), seq); + return ack; + }, + }); + await _qwpServer.StartAsync(); + + var httpPort = GetFreeTcpPort(); + _httpServer = new DummyHttpServer(); + await _httpServer.StartAsync(httpPort); + + _httpEndpoint = $"localhost:{httpPort}"; + _wsEndpoint = $"127.0.0.1:{_qwpServer.Uri.Port}"; + } + + _httpSender = Sender.New( + $"http::addr={_httpEndpoint};auto_flush_rows={AutoFlushRows};auto_flush_interval=off;auto_flush_bytes=off;"); + _wsSender = Sender.New( + $"ws::addr={_wsEndpoint};" + + $"auto_flush_rows={AutoFlushRows};auto_flush_interval=off;auto_flush_bytes=off;"); + + _wideStringG = Enumerable.Range(0, Rows).Select(i => "string-" + i).ToArray(); + _wideStringK = Enumerable.Range(0, Rows).Select(i => "k-" + i).ToArray(); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _httpSender?.Dispose(); } catch { } + try { _wsSender?.Dispose(); } catch { } + if (_qwpServer is not null) await _qwpServer.DisposeAsync(); + _httpServer?.Dispose(); + } + + // -- Narrow row (3 columns) ------------------------------------------------ + + [Benchmark(Baseline = true), BenchmarkCategory("Narrow")] + public Task Http_NarrowRow() => NarrowRowAsync(_httpSender); + + [Benchmark, BenchmarkCategory("Narrow")] + public Task Ws_NarrowRow() => NarrowRowAsync(_wsSender); + + private async Task NarrowRowAsync(ISender sender) + { + for (var i = 0; i < Rows; i++) + { + sender.Table("bench") + .Symbol("region", i % 2 == 0 ? "us" : "eu") + .Column("price", i * 1.5) + .At(DateTime.UtcNow); + } + + await sender.SendAsync(); + } + + // -- Wide row (15 columns: 3 symbols + 10 typed + string + designated TS) - + + [Benchmark(Baseline = true), BenchmarkCategory("Wide")] + public Task Http_WideRow() => WideRowAsync(_httpSender); + + [Benchmark, BenchmarkCategory("Wide")] + public Task Ws_WideRow() => WideRowAsync(_wsSender); + + private async Task WideRowAsync(ISender sender) + { + var now = DateTime.UtcNow; + for (var i = 0; i < Rows; i++) + { + sender.Table("wide") + .Symbol("a", "x").Symbol("b", "y").Symbol("c", "z") + .Column("d", (long)i).Column("e", i * 0.5) + .Column("f", i % 2 == 0).Column("g", _wideStringG[i]) + .Column("h", now) + .Column("i", (long)(i + 1)).Column("j", i * 1.25) + .Column("k", _wideStringK[i]).Column("l", i % 3 == 0) + .Column("m", (long)(i * 2)).Column("n", (i % 7) * 0.7) + .At(now); + } + + await sender.SendAsync(); + } + + // -- Multi-table (5 tables interleaved) ----------------------------------- + + [Benchmark(Baseline = true), BenchmarkCategory("MultiTable")] + public Task Http_MultiTable_5Way() => MultiTableAsync(_httpSender); + + [Benchmark, BenchmarkCategory("MultiTable")] + public Task Ws_MultiTable_5Way() => MultiTableAsync(_wsSender); + + private async Task MultiTableAsync(ISender sender) + { + var now = DateTime.UtcNow; + for (var i = 0; i < Rows; i++) + { + var tableIndex = i % 5; + sender.Table($"t{tableIndex}") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(now); + } + + await sender.SendAsync(); + } + + private static int GetFreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} + +/// +/// Job for the ingest-throughput benches ( and +/// ): 20 iterations × 5 warmup, one shot per invocation. +/// Replaces the 3-iteration fast job, whose error bars routinely exceeded the means. +/// +public class IngestThroughputConfig : ManualConfig +{ + public IngestThroughputConfig() + { + Add(DefaultConfig.Instance); + WithUnionRule(ConfigUnionRule.AlwaysUseLocal); + + AddJob(Job.Default + .WithLaunchCount(1) + .WithWarmupCount(5) + .WithIterationCount(20) + .WithInvocationCount(1) + .WithUnrollFactor(1) + .WithMinIterationTime(Perfolizer.Horology.TimeInterval.FromMilliseconds(10)) + .WithToolchain(InProcessNoEmitToolchain.Instance)); + AddColumn(StatisticColumn.Min, StatisticColumn.P95, StatisticColumn.Max); + AddDiagnoser(MemoryDiagnoser.Default); + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchLatencyWs.cs b/src/net-questdb-client-benchmarks/BenchLatencyWs.cs new file mode 100644 index 00000000..3e384ce0 --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchLatencyWs.cs @@ -0,0 +1,214 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using QuestDB; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_benchmarks; + +/// +/// Single-batch send latency, two categories. RoundTrip: SendAsync plus an +/// ACK drain, so the measured time is a full +/// send + server-process + ACK round-trip — directly comparable to the blocking HTTP baseline. +/// Handover: bare SendAsync, which returns after the async enqueue, measuring +/// caller-observed send latency without waiting for the server; the RAM and SF variants expose +/// the mmap-staging cost that store-and-forward adds to that handover. IterationCount is the +/// p50/p95/min/max sample size; override with --iterationCount N on the CLI. +/// +[Config(typeof(LatencySamplingConfig))] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory, BenchmarkLogicalGroupRule.ByParams)] +public class BenchLatencyWs +{ + private DummyQwpServer? _qwpServer; + private DummyHttpServer? _httpServer; + private string _httpEndpoint = null!; + private string _wsEndpoint = null!; + private ISender _wsSender = null!; + private ISender _wsSenderSf = null!; + private ISender _httpSender = null!; + private string _sfRoot = null!; + private long _rowSeq; + + [Params(1, 100)] + public int RowsPerBatch; + + [GlobalSetup] + public async Task Setup() + { + var realEndpoint = Environment.GetEnvironmentVariable("QDB_BENCH_ENDPOINT"); + if (!string.IsNullOrEmpty(realEndpoint)) + { + _httpEndpoint = realEndpoint; + _wsEndpoint = realEndpoint; + } + else + { + long ackSeq = 0; + _qwpServer = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref ackSeq) - 1; + var ack = new byte[9]; + ack[0] = 0x00; + BinaryPrimitives.WriteInt64LittleEndian(ack.AsSpan(1, 8), seq); + return ack; + }, + }); + await _qwpServer.StartAsync(); + + var httpPort = GetFreeTcpPort(); + _httpServer = new DummyHttpServer(); + await _httpServer.StartAsync(httpPort); + + _httpEndpoint = $"localhost:{httpPort}"; + _wsEndpoint = $"127.0.0.1:{_qwpServer.Uri.Port}"; + } + + _sfRoot = Path.Combine(Path.GetTempPath(), "qdb-sf-bench-lat-" + Guid.NewGuid().ToString("N")); + _wsSender = Sender.New($"ws::addr={_wsEndpoint};auto_flush=off;"); + _wsSenderSf = Sender.New( + $"ws::addr={_wsEndpoint};sf_dir={_sfRoot};sender_id=latbench;auto_flush=off;"); + _httpSender = Sender.New($"http::addr={_httpEndpoint};auto_flush=off;"); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _wsSender?.Dispose(); } catch { } + try { _wsSenderSf?.Dispose(); } catch { } + try { _httpSender?.Dispose(); } catch { } + if (_qwpServer is not null) await _qwpServer.DisposeAsync(); + _httpServer?.Dispose(); + if (_sfRoot is not null && Directory.Exists(_sfRoot)) + { + try { Directory.Delete(_sfRoot, recursive: true); } catch { } + } + } + + [Benchmark(Baseline = true), BenchmarkCategory("RoundTrip")] + public async Task Http_Roundtrip() + { + AppendRows(_httpSender); + await _httpSender.SendAsync(); + } + + [Benchmark, BenchmarkCategory("RoundTrip")] + public async Task Ws_SyncRoundtrip() + { + AppendRows(_wsSender); + await _wsSender.SendAsync(); + // SendAsync returns after the async enqueue; PingAsync drains the in-flight ACK window so + // the measured interval is a full server round-trip, not just caller-side handover. + await ((IQwpWebSocketSender)_wsSender).PingAsync(); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Handover")] + public async Task Ws_HandoverRam() + { + AppendRows(_wsSender); + // Bare SendAsync returns after the async enqueue into the RAM segment ring with no ACK + // wait — caller-observed send latency, the Handover-category baseline. + await _wsSender.SendAsync(); + } + + [Benchmark, BenchmarkCategory("Handover")] + public async Task Ws_HandoverSf() + { + AppendRows(_wsSenderSf); + // Same handover measurement against an sf_dir-backed sender; the delta over Ws_HandoverRam + // is the mmap-staging cost store-and-forward adds to the caller's send path. + await _wsSenderSf.SendAsync(); + } + + private void AppendRows(ISender sender) + { + for (var i = 0; i < RowsPerBatch; i++) + { + sender.Table("lat") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(DateTime.UtcNow); + } + } + + private static int GetFreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} + +/// +/// Sampling-oriented job for : every iteration is one RTT, so +/// IterationCount is the sample size feeding p50 / p95 / min / max. Defaults to 20k — solid +/// for p50–p95 and quick to run; pass --iterationCount 100000 on the CLI when a stable +/// p99 / p99.9 tail is needed. +/// +public class LatencySamplingConfig : ManualConfig +{ + public LatencySamplingConfig() + { + Add(DefaultConfig.Instance); + WithUnionRule(ConfigUnionRule.AlwaysUseLocal); + + AddJob(Job.Default + .WithLaunchCount(1) + .WithWarmupCount(5) + .WithIterationCount(20_000) + .WithInvocationCount(1) + .WithUnrollFactor(1) + .WithToolchain(InProcessNoEmitToolchain.Instance)); + AddColumn( + StatisticColumn.Min, + StatisticColumn.P67, + StatisticColumn.P80, + StatisticColumn.P85, + StatisticColumn.P90, + StatisticColumn.P95, + StatisticColumn.P100, + StatisticColumn.Max); + // Added explicitly: AlwaysUseLocal drops the [MemoryDiagnoser] attribute's diagnoser, + // so the Allocated column only appears if the local config carries it. + AddDiagnoser(MemoryDiagnoser.Default); + + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchQueryLatencyWs.cs b/src/net-questdb-client-benchmarks/BenchQueryLatencyWs.cs new file mode 100644 index 00000000..1cd89e3a --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchQueryLatencyWs.cs @@ -0,0 +1,162 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using QuestDB; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_benchmarks; + +/// +/// Single-query round-trip latency for the QWP egress client with an HTTP /exec +/// baseline — the read-side counterpart of . Each iteration is +/// one full query RTT, so IterationCount is the p50 / p95 / min / max sample size. Requires +/// QDB_BENCH_ENDPOINT pointing at a live QuestDB master — no dummy fallback because +/// hand-rolling RESULT_BATCH frames would measure the fixture, not the client. +/// +/// The bind benchmark sends a parameterised query over QWP. HTTP /exec has no native +/// bind, so its baseline inlines the value into the SQL text — functionally equivalent work +/// for a latency comparison (server still parses + plans + executes the same query shape). +/// +/// +[Config(typeof(LatencySamplingConfig))] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +public class BenchQueryLatencyWs +{ + private const string LatencyTable = "bench_query_latency"; + + private string _endpoint = null!; + private HttpClient _http = null!; + private IQwpQueryClient _ws = null!; + private RowCountHandler _handler = null!; + + [GlobalSetup] + public async Task Setup() + { + _endpoint = Environment.GetEnvironmentVariable("QDB_BENCH_ENDPOINT") + ?? throw new InvalidOperationException( + "BenchQueryLatencyWs requires QDB_BENCH_ENDPOINT (e.g. 127.0.0.1:9000) — egress bench runs against a live QuestDB master."); + + _http = new HttpClient { Timeout = TimeSpan.FromMinutes(1) }; + _ws = QueryClient.New($"ws::addr={_endpoint};"); + _handler = new RowCountHandler(); + + await ExecAsync($"DROP TABLE IF EXISTS {LatencyTable}"); + await ExecAsync($"CREATE TABLE {LatencyTable} (id LONG, ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL"); + await ExecAsync($"INSERT INTO {LatencyTable} VALUES (1, now())"); + await WaitForSeedRowAsync(); + + // Prime codec state + schema registry so the measurement window isn't paying first-query cost. + _ws.Execute($"SELECT id FROM {LatencyTable}", _handler); + _ws.Execute("SELECT x FROM long_sequence(10) WHERE x = $1", b => b.SetLong(0, 1), _handler); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _ws?.Dispose(); } catch { } + try { await ExecAsync($"DROP TABLE IF EXISTS {LatencyTable}"); } catch { } + _http?.Dispose(); + } + + [Benchmark(Baseline = true), BenchmarkCategory("SingleRow")] + public async Task Http_SelectSingleRow() => await HttpExecAsync($"SELECT id FROM {LatencyTable}"); + + [Benchmark, BenchmarkCategory("SingleRow")] + public int Ws_SelectSingleRow() + { + _handler.Reset(); + _ws.Execute($"SELECT id FROM {LatencyTable}", _handler); + return _handler.TotalRows; + } + + [Benchmark(Baseline = true), BenchmarkCategory("Bind")] + public async Task Http_SelectWhereBind() + { + var x = Random.Shared.NextInt64(1, 11); + return await HttpExecAsync($"SELECT x FROM long_sequence(10) WHERE x = {x}"); + } + + [Benchmark, BenchmarkCategory("Bind")] + public int Ws_SelectWhereBind() + { + var x = Random.Shared.NextInt64(1, 11); + _handler.Reset(); + _ws.Execute("SELECT x FROM long_sequence(10) WHERE x = $1", b => b.SetLong(0, x), _handler); + return _handler.TotalRows; + } + + private async Task HttpExecAsync(string sql) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString(sql)}"; + using var resp = await _http.GetAsync(url); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadAsStringAsync(); + return body.Length; + } + + private async Task ExecAsync(string sql) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString(sql)}"; + using var resp = await _http.GetAsync(url); + resp.EnsureSuccessStatusCode(); + } + + private async Task WaitForSeedRowAsync() + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString($"SELECT count(*) FROM {LatencyTable}")}"; + for (var attempt = 0; attempt < 60; attempt++) + { + try + { + using var resp = await _http.GetAsync(url); + if (resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(); + if (body.Contains("[[1]]", StringComparison.Ordinal)) return; + } + } + catch { } + await Task.Delay(250); + } + + throw new TimeoutException($"table {LatencyTable} did not receive its seed row within the setup window"); + } + + private sealed class RowCountHandler : QwpColumnBatchHandler + { + public int TotalRows; + + public void Reset() => TotalRows = 0; + + public override void OnBatch(QwpColumnBatch batch) => TotalRows += batch.RowCount; + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchQueryWs.cs b/src/net-questdb-client-benchmarks/BenchQueryWs.cs new file mode 100644 index 00000000..f58d430e --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchQueryWs.cs @@ -0,0 +1,455 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Order; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using QuestDB; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_benchmarks; + +/// +/// Sustained read throughput for the QWP egress client with an HTTP /exec baseline. +/// Requires QDB_BENCH_ENDPOINT pointing at a live QuestDB master (e.g. 127.0.0.1:9000) — +/// no dummy fallback because hand-rolling realistic RESULT_BATCH frames in the bench +/// setup would measure the test fixture, not the client. Both methods do equivalent work: +/// WS decodes column-major and the handler reads every cell through the typed accessors; HTTP +/// streams the JSON response and extracts every cell value (numbers + strings), so the +/// QWP-vs-HTTP ratio reflects decode cost on a matched workload, not just I/O. +/// +[Config(typeof(QueryThroughputConfig))] +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory, BenchmarkLogicalGroupRule.ByParams)] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class BenchQueryWs +{ + private const string NarrowTable = "bench_egress_narrow"; + private const string WideTable = "bench_egress_wide"; + private const string NarrowColumns = "ts, id, price, sym, note"; + private const string WideColumns = "ts, id, price, sym, note, d1, d2, d3, d4, d5, s1, s2, s3, s4, s5"; + private const int SeedRows = 10_000_000; + + private string _endpoint = null!; + private HttpClient _http = null!; + private IQwpQueryClient _wsNarrow = null!; + private IQwpQueryClient _wsWide = null!; + private CountingHandler _handler = null!; + + [Params(10_000, 100_000, 1_000_000, 10_000_000)] + public int RowCount; + + [GlobalSetup] + public async Task Setup() + { + _endpoint = Environment.GetEnvironmentVariable("QDB_BENCH_ENDPOINT") + ?? throw new InvalidOperationException( + "BenchQueryWs requires QDB_BENCH_ENDPOINT (e.g. 127.0.0.1:9000) to be set — egress bench runs against a live QuestDB master."); + + _http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + _wsNarrow = QueryClient.New($"ws::addr={_endpoint};"); + // The wide schema's high-cardinality symbol columns make a default-sized RESULT_BATCH + // (16384 rows) overflow the server's egress send buffer and abort the query, so cap the + // per-batch row count for wide. Narrow rows are small enough to stay uncapped. + _wsWide = QueryClient.New($"ws::addr={_endpoint};max_batch_rows=4096;"); + _handler = new CountingHandler(); + + await DropAsync(NarrowTable); + await DropAsync(WideTable); + await CreateNarrowTableAsync(); + await CreateWideTableAsync(); + await SeedNarrowAsync(SeedRows); + await SeedWideAsync(SeedRows); + await WaitForRowsAsync(NarrowTable, SeedRows); + await WaitForRowsAsync(WideTable, SeedRows); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _wsNarrow?.Dispose(); } catch { } + try { _wsWide?.Dispose(); } catch { } + try { await DropAsync(NarrowTable); } catch { } + try { await DropAsync(WideTable); } catch { } + _http?.Dispose(); + } + + [Benchmark(Baseline = true), BenchmarkCategory("Narrow")] + public async Task Http_NarrowSelect() => await HttpSelectAsync(NarrowTable, NarrowColumns); + + [Benchmark, BenchmarkCategory("Narrow")] + public long Ws_NarrowSelect() => WsSelect(_wsNarrow, NarrowTable, NarrowColumns); + + [Benchmark(Baseline = true), BenchmarkCategory("Wide")] + public async Task Http_WideSelect() => await HttpSelectAsync(WideTable, WideColumns); + + [Benchmark, BenchmarkCategory("Wide")] + public long Ws_WideSelect() => WsSelect(_wsWide, WideTable, WideColumns); + + private long WsSelect(IQwpQueryClient ws, string table, string columns) + { + _handler.Reset(); + ws.Execute($"SELECT {columns} FROM {table} LIMIT {RowCount}", _handler); + return _handler.TotalRows ^ _handler.Checksum; + } + + private async Task HttpSelectAsync(string table, string columns) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString($"SELECT {columns} FROM {table} LIMIT {RowCount}")}"; + using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + resp.EnsureSuccessStatusCode(); + await using var stream = await resp.Content.ReadAsStreamAsync(); + return await ParseRowsStreamingAsync(stream); + } + + private Task CreateNarrowTableAsync() => + ExecAsync( + $"CREATE TABLE {NarrowTable} (ts TIMESTAMP, id LONG, price DOUBLE, sym SYMBOL, note VARCHAR) " + + "TIMESTAMP(ts) PARTITION BY HOUR WAL"); + + private Task CreateWideTableAsync() => + ExecAsync( + $"CREATE TABLE {WideTable} (ts TIMESTAMP, id LONG, price DOUBLE, sym SYMBOL, note VARCHAR, " + + "d1 DOUBLE, d2 DOUBLE, d3 DOUBLE, d4 DOUBLE, d5 DOUBLE, " + + "s1 SYMBOL capacity 200000, s2 SYMBOL capacity 200000, s3 SYMBOL capacity 200000, " + + "s4 SYMBOL capacity 200000, s5 SYMBOL capacity 200000) " + + "TIMESTAMP(ts) PARTITION BY HOUR WAL"); + + private async Task SeedNarrowAsync(int rows) + { + using var sender = Sender.New( + $"ws::addr={_endpoint};auto_flush_rows=10000;auto_flush_interval=off;auto_flush_bytes=off;"); + var now = DateTime.UtcNow; + for (var i = 0; i < rows; i++) + { + sender.Table(NarrowTable) + .Column("id", (long)i) + .Column("price", i * 1.5) + .Symbol("sym", i % 2 == 0 ? "us" : "eu") + .Column("note", "note-" + i) + .At(now); + } + await sender.SendAsync(); + } + + private async Task SeedWideAsync(int rows) + { + using var sender = Sender.New( + $"ws::addr={_endpoint};auto_flush_rows=10000;auto_flush_interval=off;auto_flush_bytes=off;"); + var now = DateTime.UtcNow; + for (var i = 0; i < rows; i++) + { + sender.Table(WideTable) + .Column("id", (long)i) + .Column("price", i * 1.5) + .Symbol("sym", i % 2 == 0 ? "us" : "eu") + .Column("note", "note-" + i) + .Column("d1", i * 0.5).Column("d2", i * 1.25).Column("d3", (i % 7) * 0.7) + .Column("d4", i * 2.0).Column("d5", i * 0.25) + .Symbol("s1", "s1-" + (i % 100_000)).Symbol("s2", "s2-" + (i % 100_000)) + .Symbol("s3", "s3-" + (i % 100_000)).Symbol("s4", "s4-" + (i % 100_000)) + .Symbol("s5", "s5-" + (i % 100_000)) + .At(now); + } + await sender.SendAsync(); + } + + private Task DropAsync(string table) => ExecAsync($"DROP TABLE IF EXISTS {table}"); + + private async Task ExecAsync(string sql) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString(sql)}"; + using var resp = await _http.GetAsync(url); + resp.EnsureSuccessStatusCode(); + } + + private async Task WaitForRowsAsync(string table, long minimum) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString($"SELECT count(*) FROM {table}")}"; + for (var attempt = 0; attempt < 60; attempt++) + { + try + { + using var resp = await _http.GetAsync(url); + if (resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(); + if (TryParseCount(body, out var n) && n >= minimum) return; + } + } + catch { } + await Task.Delay(500); + } + + throw new TimeoutException($"table {table} did not reach {minimum} rows within seeding window"); + } + + private static async Task ParseRowsStreamingAsync(Stream stream) + { + // True streaming Utf8JsonReader walk — refill a buffer as the network feeds it. Avoids the + // CopyToAsync(MemoryStream) penalty that would charge HTTP an unfair allocator tax. Every + // cell value is extracted (not just counted) so the work matches the QWP typed accessors. + var buffer = new byte[64 * 1024]; + var filled = 0; + long count = 0; + long checksum = 0; + var sawDataset = false; + var depth = 0; + var state = new JsonReaderState(); + var done = false; + + while (!done) + { + var read = await stream.ReadAsync(buffer.AsMemory(filled, buffer.Length - filled)); + var isFinal = read == 0; + var consumed = ProcessChunk(buffer.AsSpan(0, filled + read), isFinal, ref state, + ref sawDataset, ref depth, ref count, ref checksum, out var datasetEnded); + + if (datasetEnded || isFinal) + { + done = true; + break; + } + + var leftover = filled + read - consumed; + if (leftover > 0) + { + if (consumed == 0) + { + var grown = new byte[buffer.Length * 2]; + Array.Copy(buffer, grown, filled + read); + buffer = grown; + } + else + { + Array.Copy(buffer, consumed, buffer, 0, leftover); + } + } + filled = leftover; + } + + return count ^ checksum; + } + + private static int ProcessChunk( + ReadOnlySpan span, bool isFinal, ref JsonReaderState state, + ref bool sawDataset, ref int depth, ref long count, ref long checksum, out bool datasetEnded) + { + datasetEnded = false; + var reader = new Utf8JsonReader(span, isFinal, state); + while (reader.Read()) + { + if (!sawDataset) + { + if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("dataset")) + { + sawDataset = true; + } + continue; + } + + switch (reader.TokenType) + { + case JsonTokenType.StartArray: + depth++; + if (depth == 2) count++; + break; + case JsonTokenType.EndArray: + depth--; + if (depth == 0) + { + datasetEnded = true; + state = reader.CurrentState; + return (int)reader.BytesConsumed; + } + break; + case JsonTokenType.Number: + // depth == 2 is a scalar cell inside a row array; extract it so HTTP does the + // same per-value decode work as the QWP typed accessors. + if (depth == 2) + checksum ^= reader.TryGetInt64(out var l) + ? l + : BitConverter.DoubleToInt64Bits(reader.GetDouble()); + break; + case JsonTokenType.String: + if (depth == 2) checksum ^= reader.ValueSpan.Length; + break; + case JsonTokenType.True: + if (depth == 2) checksum ^= 1; + break; + } + } + + state = reader.CurrentState; + return (int)reader.BytesConsumed; + } + + private static bool TryParseCount(string body, out long count) + { + const string marker = "\"dataset\":[["; + var idx = body.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) + { + count = 0; + return false; + } + + var start = idx + marker.Length; + var end = body.IndexOf(']', start); + return long.TryParse(body.Substring(start, end - start), out count); + } + + private sealed class CountingHandler : QwpColumnBatchHandler + { + public int TotalRows; + public long Checksum; + + public void Reset() + { + TotalRows = 0; + Checksum = 0; + } + + public override void OnBatch(QwpColumnBatch batch) + { + TotalRows += batch.RowCount; + // Walk every column on every row through the typed accessors so the bench actually + // exercises the primitive read path. Earlier shape checked only column 0; in the + // narrow schema column 0 is a Symbol so the hot Long/Double accessor went uncalled. + var cols = batch.ColumnCount; + var rows = batch.RowCount; + long acc = 0; + for (var c = 0; c < cols; c++) + { + var t = batch.GetColumnWireType(c); + switch (t) + { + case QuestDB.Enums.QwpTypeCode.Long: + case QuestDB.Enums.QwpTypeCode.Date: + case QuestDB.Enums.QwpTypeCode.Timestamp: + case QuestDB.Enums.QwpTypeCode.TimestampNanos: + for (var r = 0; r < rows; r++) acc ^= batch.GetLongValue(c, r); + break; + case QuestDB.Enums.QwpTypeCode.Int: + case QuestDB.Enums.QwpTypeCode.IPv4: + for (var r = 0; r < rows; r++) acc ^= batch.GetIntValue(c, r); + break; + case QuestDB.Enums.QwpTypeCode.Double: + for (var r = 0; r < rows; r++) + acc ^= BitConverter.DoubleToInt64Bits(batch.GetDoubleValue(c, r)); + break; + case QuestDB.Enums.QwpTypeCode.Float: + for (var r = 0; r < rows; r++) + acc ^= BitConverter.SingleToInt32Bits(batch.GetFloatValue(c, r)); + break; + case QuestDB.Enums.QwpTypeCode.Boolean: + for (var r = 0; r < rows; r++) acc ^= batch.GetBoolValue(c, r) ? 1 : 0; + break; + case QuestDB.Enums.QwpTypeCode.Symbol: + for (var r = 0; r < rows; r++) acc ^= batch.GetSymbolId(c, r); + break; + case QuestDB.Enums.QwpTypeCode.Varchar: + for (var r = 0; r < rows; r++) acc ^= batch.GetStringSpan(c, r).Length; + break; + } + } + Checksum ^= acc; + } + } +} + +/// +/// 20 iterations × 5 warmups gives < 5% margin on multi-millisecond workloads. Adds a +/// Rows/sec column derived from the RowCount param so the punchy throughput +/// number doesn't have to be re-derived by hand. +/// +public class QueryThroughputConfig : ManualConfig +{ + public QueryThroughputConfig() + { + Add(DefaultConfig.Instance); + WithUnionRule(ConfigUnionRule.AlwaysUseLocal); + + AddJob(Job.Default + .WithLaunchCount(1) + .WithWarmupCount(5) + .WithIterationCount(20) + .WithInvocationCount(1) + .WithUnrollFactor(1) + .WithMinIterationTime(Perfolizer.Horology.TimeInterval.FromMilliseconds(10)) + .WithToolchain(InProcessNoEmitToolchain.Instance)); + AddColumn(StatisticColumn.Min, StatisticColumn.P95, StatisticColumn.Max); + AddColumn(new RowsPerSecondColumn()); + // Added explicitly: AlwaysUseLocal drops the [MemoryDiagnoser] attribute's diagnoser, + // so the Allocated column only appears if the local config carries it. + AddDiagnoser(MemoryDiagnoser.Default); + } +} + +internal sealed class RowsPerSecondColumn : IColumn +{ + public string Id => nameof(RowsPerSecondColumn); + public string ColumnName => "Rows/sec"; + public bool AlwaysShow => true; + public ColumnCategory Category => ColumnCategory.Statistics; + public int PriorityInCategory => 100; + public bool IsNumeric => true; + public UnitType UnitType => UnitType.Dimensionless; + public string Legend => "Rows processed per second (RowCount / Mean)"; + + public bool IsAvailable(Summary summary) => true; + public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => false; + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase) => + GetValue(summary, benchmarkCase, SummaryStyle.Default); + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) + { + var report = summary[benchmarkCase]; + if (report?.ResultStatistics is null) return "-"; + + var rowParam = benchmarkCase.Parameters.Items.FirstOrDefault(p => p.Name == "RowCount"); + if (rowParam?.Value is not int rows) return "-"; + + var rowsPerSec = rows / (report.ResultStatistics.Mean / 1_000_000_000.0); + return rowsPerSec switch + { + >= 1_000_000 => $"{rowsPerSec / 1_000_000:F2}M", + >= 1_000 => $"{rowsPerSec / 1_000:F0}K", + _ => rowsPerSec.ToString("F0"), + }; + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchSfAppend.cs b/src/net-questdb-client-benchmarks/BenchSfAppend.cs new file mode 100644 index 00000000..0d9aedab --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchSfAppend.cs @@ -0,0 +1,143 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using BenchmarkDotNet.Attributes; +using QuestDB; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_benchmarks; + +/// +/// Per-row append latency for the SF cursor engine wired through the public sender API. +/// Warm WebSocket connection + fast-acking server, measure the time to publish a single +/// row + flush. +/// +public class BenchSfAppend +{ + private DummyQwpServer? _server; + private string _wsEndpoint = null!; + private string _sfRoot = null!; + private ISender _sfSender = null!; + private ISender _wsSender = null!; + private long _rowSeq; + + [Params(1, 100, 1000)] + public int RowsPerSend; + + [GlobalSetup] + public async Task Setup() + { + _sfRoot = Path.Combine(Path.GetTempPath(), "qdb-sf-bench-" + Guid.NewGuid().ToString("N")); + + var realEndpoint = Environment.GetEnvironmentVariable("QDB_BENCH_ENDPOINT"); + if (!string.IsNullOrEmpty(realEndpoint)) + { + _wsEndpoint = realEndpoint; + } + else + { + long ackSeq = 0; + _server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref ackSeq) - 1; + var ack = new byte[9]; + ack[0] = 0x00; + BinaryPrimitives.WriteInt64LittleEndian(ack.AsSpan(1, 8), seq); + return ack; + }, + }); + await _server.StartAsync(); + _wsEndpoint = $"127.0.0.1:{_server.Uri.Port}"; + } + + _sfSender = Sender.New( + $"ws::addr={_wsEndpoint};auto_flush=off;sf_dir={_sfRoot};sender_id=bench;"); + _wsSender = Sender.New( + $"ws::addr={_wsEndpoint};auto_flush=off;"); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _sfSender?.Dispose(); } catch { } + try { _wsSender?.Dispose(); } catch { } + if (_server is not null) + { + await _server.DisposeAsync(); + } + + if (Directory.Exists(_sfRoot)) + { + try { Directory.Delete(_sfRoot, recursive: true); } catch { } + } + } + + [Benchmark(Baseline = true)] + public void NonSf_AppendAndSend() + { + for (var i = 0; i < RowsPerSend; i++) + { + _wsSender.Table("bench") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(DateTime.UtcNow); + } + + _wsSender.Send(); + } + + [Benchmark] + public void Sf_AppendAndSend() + { + for (var i = 0; i < RowsPerSend; i++) + { + _sfSender.Table("bench") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(DateTime.UtcNow); + } + + _sfSender.Send(); + } + + [Benchmark] + public void Sf_AppendSendAndPing() + { + for (var i = 0; i < RowsPerSend; i++) + { + _sfSender.Table("bench") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(DateTime.UtcNow); + } + + _sfSender.Send(); + ((IQwpWebSocketSender)_sfSender).Ping(); + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchSfThroughput.cs b/src/net-questdb-client-benchmarks/BenchSfThroughput.cs new file mode 100644 index 00000000..f2b23c44 --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchSfThroughput.cs @@ -0,0 +1,135 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using BenchmarkDotNet.Attributes; +using QuestDB; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_benchmarks; + +/// +/// SF vs non-SF sustained throughput. Senders live across iterations so per-invocation cost +/// is row-encoding + frame I/O + ACK wait, not slot-lock acquire / mmap setup / engine spin-up. +/// Acceptance: SF overhead should stay within ~30% of non-SF. +/// +[Config(typeof(IngestThroughputConfig))] +public class BenchSfThroughput +{ + private DummyQwpServer? _qwpServer; + private string _wsEndpoint = null!; + private string _sfRoot = null!; + private ISender _wsNoSf = null!; + private ISender _wsWithSf = null!; + + [Params(200_000, 500_000)] + public int Rows; + + [GlobalSetup] + public async Task Setup() + { + _sfRoot = Path.Combine(Path.GetTempPath(), "qdb-sf-bench-thru-" + Guid.NewGuid().ToString("N")); + + var realEndpoint = Environment.GetEnvironmentVariable("QDB_BENCH_ENDPOINT"); + if (!string.IsNullOrEmpty(realEndpoint)) + { + _wsEndpoint = realEndpoint; + } + else + { + long ackSeq = 0; + _qwpServer = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref ackSeq) - 1; + var ack = new byte[9]; + ack[0] = 0x00; + BinaryPrimitives.WriteInt64LittleEndian(ack.AsSpan(1, 8), seq); + return ack; + }, + }); + await _qwpServer.StartAsync(); + _wsEndpoint = $"127.0.0.1:{_qwpServer.Uri.Port}"; + } + + _wsNoSf = Sender.New( + $"ws::addr={_wsEndpoint};" + + $"auto_flush_rows=1000;auto_flush_interval=off;auto_flush_bytes=off;"); + + _wsWithSf = Sender.New( + $"ws::addr={_wsEndpoint};" + + $"sf_dir={_sfRoot};sender_id=bench;" + + $"auto_flush_rows=1000;auto_flush_interval=off;auto_flush_bytes=off;"); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _wsNoSf?.Dispose(); } catch { } + try { _wsWithSf?.Dispose(); } catch { } + if (_qwpServer is not null) await _qwpServer.DisposeAsync(); + if (Directory.Exists(_sfRoot)) + { + try { Directory.Delete(_sfRoot, recursive: true); } catch { } + } + } + + [Benchmark(Baseline = true)] + public async Task Ws_NoSf() + { + for (var i = 0; i < Rows; i++) + { + _wsNoSf.Table("thru") + .Symbol("region", i % 2 == 0 ? "us" : "eu") + .Column("v", (long)i) + .At(DateTime.UtcNow); + } + + await _wsNoSf.SendAsync(); + // Block until every in-flight batch has been ACKed, so the comparison against Ws_WithSf + // (which already pays this cost via its engine flush) is symmetric. + ((IQwpWebSocketSender)_wsNoSf).Ping(); + } + + [Benchmark] + public async Task Ws_WithSf() + { + for (var i = 0; i < Rows; i++) + { + _wsWithSf.Table("thru") + .Symbol("region", i % 2 == 0 ? "us" : "eu") + .Column("v", (long)i) + .At(DateTime.UtcNow); + } + + await _wsWithSf.SendAsync(); + ((IQwpWebSocketSender)_wsWithSf).Ping(); + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/Program.cs b/src/net-questdb-client-benchmarks/Program.cs index 2b8b965d..6f76542d 100644 --- a/src/net-questdb-client-benchmarks/Program.cs +++ b/src/net-questdb-client-benchmarks/Program.cs @@ -23,6 +23,7 @@ ******************************************************************************/ +using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; @@ -34,10 +35,28 @@ public class Program { public static void Main(string[] args) { - var config = - DefaultConfig.Instance.AddJob(Job.MediumRun.WithLaunchCount(1) - .WithToolchain(InProcessNoEmitToolchain.Instance)) - .WithOptions(ConfigOptions.DisableOptimizationsValidator); + var fastJob = Job.Default + .WithLaunchCount(1) + .WithWarmupCount(2) + .WithIterationCount(3) + .WithInvocationCount(1) + .WithUnrollFactor(1) + // Stateful benches (open sender + send N rows) run as one shot per invocation; tell BDN + // not to flag iterations < 100ms as "too small" — the workload is already meaningful. + .WithMinIterationTime(Perfolizer.Horology.TimeInterval.FromMilliseconds(10)) + .WithToolchain(InProcessNoEmitToolchain.Instance); + + var config = DefaultConfig.Instance + .AddJob(fastJob) + .AddColumn(StatisticColumn.Min, StatisticColumn.Max, StatisticColumn.P95) + .WithOptions(ConfigOptions.DisableOptimizationsValidator); + + if (args is { Length: > 0 }) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); + return; + } + RunConnectionChurnVsServerBench2(config); } @@ -57,7 +76,39 @@ public static void RunConnectionChurnVsServerBench(ManualConfig config) } public static void RunConnectionChurnVsServerBench2(ManualConfig config) - { + { BenchmarkRunner.Run(config); } + +#if NET7_0_OR_GREATER + public static void RunSfAppendBench(ManualConfig config) + { + BenchmarkRunner.Run(config); + } + + public static void RunInsertsWsBench(ManualConfig config) + { + BenchmarkRunner.Run(config); + } + + public static void RunLatencyWsBench(ManualConfig config) + { + BenchmarkRunner.Run(config); + } + + public static void RunSfThroughputBench(ManualConfig config) + { + BenchmarkRunner.Run(config); + } + + public static void RunQueryWsBench(ManualConfig config) + { + BenchmarkRunner.Run(config); + } + + public static void RunQueryLatencyWsBench(ManualConfig config) + { + BenchmarkRunner.Run(config); + } +#endif } \ No newline at end of file diff --git a/src/net-questdb-client-tests/BufferTests.cs b/src/net-questdb-client-tests/BufferTests.cs index df9ec772..aee1bac4 100644 --- a/src/net-questdb-client-tests/BufferTests.cs +++ b/src/net-questdb-client-tests/BufferTests.cs @@ -1,5 +1,6 @@ using NUnit.Framework; using QuestDB.Buffers; +using QuestDB.Utils; namespace net_questdb_client_tests; @@ -170,4 +171,57 @@ public void DecimalNegationBoundaryCarry() }); } + [Test] + public void At_LocalDateTimeKind_ConvertedToUtc_WhenEnabled() + { + var local = DateTime.SpecifyKind(new DateTime(1970, 1, 1, 0, 0, 1), DateTimeKind.Local); + var bufferLocal = new BufferV3(128, 128, 128, convertLocalToUtc: true); + bufferLocal.Table("t").Column("x", 1).At(local); + + // ToUniversalTime() yields a Utc-kind value, which is never re-converted. + var bufferUtc = new BufferV3(128, 128, 128, convertLocalToUtc: true); + bufferUtc.Table("t").Column("x", 1).At(local.ToUniversalTime()); + + Assert.That(bufferLocal.GetSendBuffer().ToArray(), Is.EqualTo(bufferUtc.GetSendBuffer().ToArray())); + } + + [Test] + public void At_LocalDateTimeKind_NotConverted_ByDefault() + { + var local = DateTime.SpecifyKind(new DateTime(1970, 1, 1, 0, 0, 1), DateTimeKind.Local); + var bufferDefault = new BufferV3(128, 128, 128); + bufferDefault.Table("t").Column("x", 1).At(local); + + // Default writes the raw wall-clock ticks: identical to the same instant tagged Utc. + var bufferRaw = new BufferV3(128, 128, 128); + bufferRaw.Table("t").Column("x", 1).At(DateTime.SpecifyKind(local, DateTimeKind.Utc)); + + Assert.That(bufferDefault.GetSendBuffer().ToArray(), Is.EqualTo(bufferRaw.GetSendBuffer().ToArray())); + } + + [Test] + public void Column_LocalDateTime_ConvertedToUtc_WhenEnabled() + { + var local = DateTime.SpecifyKind(new DateTime(1970, 1, 1, 0, 0, 1), DateTimeKind.Local); + var bufferLocal = new BufferV3(128, 128, 128, convertLocalToUtc: true); + bufferLocal.Table("t").Column("ts", local).At(2L); + + var bufferUtc = new BufferV3(128, 128, 128, convertLocalToUtc: true); + bufferUtc.Table("t").Column("ts", local.ToUniversalTime()).At(2L); + + Assert.That(bufferLocal.GetSendBuffer().ToArray(), Is.EqualTo(bufferUtc.GetSendBuffer().ToArray())); + } + + [Test] + public void Column_LocalDateTime_NotConverted_ByDefault() + { + var local = DateTime.SpecifyKind(new DateTime(1970, 1, 1, 0, 0, 1), DateTimeKind.Local); + var bufferDefault = new BufferV3(128, 128, 128); + bufferDefault.Table("t").Column("ts", local).At(2L); + + var bufferRaw = new BufferV3(128, 128, 128); + bufferRaw.Table("t").Column("ts", DateTime.SpecifyKind(local, DateTimeKind.Utc)).At(2L); + + Assert.That(bufferDefault.GetSendBuffer().ToArray(), Is.EqualTo(bufferRaw.GetSendBuffer().ToArray())); + } } \ No newline at end of file diff --git a/src/net-questdb-client-tests/HttpTests.cs b/src/net-questdb-client-tests/HttpTests.cs index 3218fb1f..d5da2d1b 100644 --- a/src/net-questdb-client-tests/HttpTests.cs +++ b/src/net-questdb-client-tests/HttpTests.cs @@ -85,6 +85,66 @@ await sender.Table("metrics") await server.StopAsync(); } + [Test] + public async Task DecimalColumns_TypedOverloads_OverIlp() + { + using var server = new DummyHttpServer(withBasicAuth: false); + await server.StartAsync(HttpPort, new[] + { + 1, 2, + 3, + }); + using var sender = + Sender.New( + $"http::addr={Host}:{HttpPort};protocol_version=3;tls_verify=unsafe_off;auto_flush=off;"); + + await sender.Table("metrics") + .Symbol("tag", "value") + // (decimal, scale): width name is irrelevant over ILP; value is coerced to scale. + .ColumnDecimal64("d64", 12.34m, 2) + .ColumnDecimal64("d64_rounded", 12.345m, 2) // half away from zero -> 12.35 + .ColumnDecimal128("d128", 12.34m, 2) + // raw-limb overloads reconstruct a System.Decimal for the ILP encoder. + .ColumnDecimal64("d64_lo", 100L, 2) // 1.00 + .ColumnDecimal128("d128_lo", 255L, 0L, 0) // 255 + .ColumnDecimal256("d256_lo", -1L, -1L, -1L, -1L, 0) // -1 + .AtAsync(new DateTime(1970, 01, 01, 0, 0, 1)); + + await sender.SendAsync(); + + var buffer = server.GetReceivedBytes().ToArray(); + DecimalTestHelpers.AssertDecimalField(buffer, "d64", 2, new byte[] { 0x04, 0xD2 }); // 1234 + DecimalTestHelpers.AssertDecimalField(buffer, "d64_rounded", 2, new byte[] { 0x04, 0xD3 }); // 1235 + DecimalTestHelpers.AssertDecimalField(buffer, "d128", 2, new byte[] { 0x04, 0xD2 }); + DecimalTestHelpers.AssertDecimalField(buffer, "d64_lo", 2, new byte[] { 0x64 }); // 100 + DecimalTestHelpers.AssertDecimalField(buffer, "d128_lo", 0, new byte[] { 0x00, 0xFF }); // 255 (positive, high bit set) + DecimalTestHelpers.AssertDecimalField(buffer, "d256_lo", 0, new byte[] { 0xFF }); // -1 + await server.StopAsync(); + } + + [Test] + public async Task DecimalColumns_TypedOverloads_RejectUnrepresentableOverIlp() + { + using var server = new DummyHttpServer(withBasicAuth: false); + await server.StartAsync(HttpPort, new[] + { + 1, 2, + 3, + }); + using var sender = + Sender.New( + $"http::addr={Host}:{HttpPort};protocol_version=3;tls_verify=unsafe_off;auto_flush=off;"); + + var row = sender.Table("metrics"); + // Scale beyond the type's maximum (Decimal64 caps at 18). + Assert.Throws(() => row.ColumnDecimal64("bad_scale", 1m, 20)); + // Scale representable on QWP (Decimal128 allows 38) but not over ILP's System.Decimal (max 28). + Assert.Throws(() => row.ColumnDecimal128("scale_29", 1L, 0L, 29)); + // Magnitude 2^96 exceeds System.Decimal's 96-bit mantissa. + Assert.Throws(() => row.ColumnDecimal128("too_big", 0L, 0x1_0000_0000L, 0)); + await server.StopAsync(); + } + [Test] public async Task DecimalColumns() { @@ -1406,7 +1466,7 @@ public async Task TransactionMultipleTypes() await sender.Transaction("tableName").Symbol("foo", "bah").AtAsync(86400000000000); await sender.Column("foo", 123).AtAsync(86400000000000); await sender.Column("foo", 123d).AtAsync(86400000000000); - await sender.Column("foo", new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).AtAsync(86400000000000); + await sender.Column("foo", new DateTime(1970, 1, 1)).AtAsync(86400000000000); await sender.Column("foo", new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))) .AtAsync(86400000000000); await sender.Column("foo", false).AtAsync(86400000000000); diff --git a/src/net-questdb-client-tests/MultiUrlHttpTests.cs b/src/net-questdb-client-tests/MultiUrlHttpTests.cs index 84dd00da..b9ec0742 100644 --- a/src/net-questdb-client-tests/MultiUrlHttpTests.cs +++ b/src/net-questdb-client-tests/MultiUrlHttpTests.cs @@ -312,7 +312,6 @@ public void AddressProvider_RoundRobinRotation() [Test] public void AddressProvider_ParseHostAndPort() { - // Test host and port parsing with various formats var provider1 = new AddressProvider(new[] { "192.168.1.1:9000", }); Assert.That(provider1.CurrentHost, Is.EqualTo("192.168.1.1")); Assert.That(provider1.CurrentPort, Is.EqualTo(9000)); @@ -321,7 +320,6 @@ public void AddressProvider_ParseHostAndPort() Assert.That(provider2.CurrentHost, Is.EqualTo("example.com")); Assert.That(provider2.CurrentPort, Is.EqualTo(8080)); - // IPv6 addresses with port (format: [ipv6]:port) var provider3 = new AddressProvider(new[] { "[::1]:9000", }); Assert.That(provider3.CurrentHost, Is.EqualTo("[::1]")); Assert.That(provider3.CurrentPort, Is.EqualTo(9000)); @@ -330,29 +328,22 @@ public void AddressProvider_ParseHostAndPort() [Test] public void AddressProvider_IPv6Parsing() { - // Test various IPv6 address formats - - // Simple loopback with port var provider1 = new AddressProvider(new[] { "[::1]:9000", }); Assert.That(provider1.CurrentHost, Is.EqualTo("[::1]")); Assert.That(provider1.CurrentPort, Is.EqualTo(9000)); - // Full IPv6 address with port var provider2 = new AddressProvider(new[] { "[2001:db8::1]:9000", }); Assert.That(provider2.CurrentHost, Is.EqualTo("[2001:db8::1]")); Assert.That(provider2.CurrentPort, Is.EqualTo(9000)); - // IPv6 with many colons var provider3 = new AddressProvider(new[] { "[fe80::1:2:3:4]:8080", }); Assert.That(provider3.CurrentHost, Is.EqualTo("[fe80::1:2:3:4]")); Assert.That(provider3.CurrentPort, Is.EqualTo(8080)); - // IPv6 without port (should return -1 for port) var provider4 = new AddressProvider(new[] { "[::1]", }); Assert.That(provider4.CurrentHost, Is.EqualTo("[::1]")); Assert.That(provider4.CurrentPort, Is.EqualTo(-1)); - // IPv6 with different port numbers var provider5 = new AddressProvider(new[] { "[::1]:29000", }); Assert.That(provider5.CurrentHost, Is.EqualTo("[::1]")); Assert.That(provider5.CurrentPort, Is.EqualTo(29000)); diff --git a/src/net-questdb-client-tests/QuestDbEgressAlterFuzzTests.cs b/src/net-questdb-client-tests/QuestDbEgressAlterFuzzTests.cs new file mode 100644 index 00000000..dbc63b43 --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbEgressAlterFuzzTests.cs @@ -0,0 +1,437 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_tests; + +/// Live-server egress ALTER fuzz; port of egress_live_server_alter_fuzz.rs. +[TestFixture] +public class QuestDbEgressAlterFuzzTests +{ + private const int IlpPort = 19509; + private const int HttpPort = 19500; + private const ulong DefaultSeed = 0x5EED_1234_ABCD_0001UL; + + private static readonly string[] BaseCols = { "id", "v", "cat", "ts" }; + + [Test] + public async Task SelectAlterSequence() + { + var rng = new SplitMix64(SeedFor(nameof(SelectAlterSequence))); + + var rowCount = 50 + (int)rng.GenRange(951); + var spacing = PickSpacingMicros(rng); + var opCount = 15 + (int)rng.GenRange(26); + var structuralProbPermil = 150 + (int)rng.GenRange(251); + var maxLiveAdded = 2 + (int)rng.GenRange(5); + var compression = PickCompression(rng); + TestContext.Progress.WriteLine( + $"[select_alter_sequence] rows={rowCount} spacing={spacing} ops={opCount} " + + $"structuralPermil={structuralProbPermil} maxLiveAdded={maxLiveAdded} compression={compression}"); + + var server = new QuestDbManager(IlpPort, HttpPort); + try + { + await server.StartAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) + { + await server.DisposeAsync(); + Assert.Ignore($"QWP egress ALTER fuzz needs a QuestDB master build: {ex.Message}"); + return; + } + + try + { + var http = server.GetHttpEndpoint(); + const string table = "fz_seq"; + + await ExecAsync(http, $"drop table if exists \"{table}\""); + await ExecAsync(http, + $"create table \"{table}\" (id LONG, v DOUBLE, cat SYMBOL, ts TIMESTAMP) " + + "timestamp(ts) partition by day wal"); + await ExecAsync(http, + $"insert into \"{table}\" select x, x * 1.5, " + + "case when x % 4 = 0 then 'a' when x % 4 = 1 then 'b' " + + "when x % 4 = 2 then 'c' else 'd' end, " + + $"CAST((x - 1) * {spacing}L AS TIMESTAMP) from long_sequence({rowCount})"); + await WaitForRowCountAsync(http, table, rowCount); + + var conn = $"ws::addr={server.GetWebSocketEndpoint()};path={QwpConstants.ReadPath};target=any;" + + (compression.Length > 0 ? compression + ";" : string.Empty); + using var client = QueryClient.New(conn); + + RunSelectShape(client, rng, 0, table, rowCount, spacing, Array.Empty()); + + var liveAdded = new List(); + var nextColumnId = 0; + + for (var op = 0; op < opCount; op++) + { + var wantStructural = rng.GenRange(1000) < (ulong)structuralProbPermil; + var canAdd = liveAdded.Count < maxLiveAdded; + var canDrop = liveAdded.Count > 0; + var didStructural = false; + + if (wantStructural) + { + var doAdd = (canAdd, canDrop) switch + { + (true, false) => true, + (false, true) => false, + (true, true) => rng.GenRange(10) < 6, + _ => false, + }; + + if (canAdd && doAdd) + { + var name = $"extra_{nextColumnId++}"; + await ExecAsync(http, $"alter table \"{table}\" add column \"{name}\" VARCHAR"); + liveAdded.Add(name); + await WaitForColumnCountAsync(http, table, BaseCols.Length + liveAdded.Count); + didStructural = true; + } + else if (canDrop && !doAdd) + { + var victim = liveAdded[(int)rng.GenRange((ulong)liveAdded.Count)]; + liveAdded.Remove(victim); + await ExecAsync(http, $"alter table \"{table}\" drop column \"{victim}\""); + await WaitForColumnCountAsync(http, table, BaseCols.Length + liveAdded.Count); + didStructural = true; + } + } + + if (!didStructural) + { + var shape = (int)rng.GenRange(6); + RunSelectShape(client, rng, shape, table, rowCount, spacing, liveAdded.ToArray()); + } + } + } + finally + { + await server.DisposeAsync(); + } + } + + private static void RunSelectShape( + IQwpQueryClient client, SplitMix64 rng, int shape, string table, + int totalRows, long spacing, string[] liveAdded) + { + var label = $"shape={shape}"; + switch (shape) + { + case 0: + { + var rows = Query(client, $"select id from \"{table}\""); + Assert.That(rows.Count, Is.EqualTo(totalRows), $"{label}: row_count drift"); + for (var r = 0; r < rows.Count; r++) + { + Assert.That(rows[r][0], Is.EqualTo((long)(r + 1)), $"{label}: row {r} id-mismatch"); + } + break; + } + case 1: + { + var threshold = 1 + (long)rng.GenRange((ulong)Math.Max(1, totalRows - 1)); + var rows = Query(client, $"select id, v from \"{table}\" where id > {threshold}"); + Assert.That(rows.Count, Is.EqualTo(totalRows - threshold), $"{label}: row_count drift"); + for (var r = 0; r < rows.Count; r++) + { + var id = threshold + r + 1; + Assert.That(rows[r][0], Is.EqualTo(id), $"{label}: id-mismatch"); + Assert.That(rows[r][1], Is.EqualTo(ExpectedV(id)), $"{label}: v-mismatch id={id}"); + } + break; + } + case 2: + { + var rows = Query(client, $"select cat, count(*) as c from \"{table}\""); + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + { + counts[(string)row[0]!] = (long)row[1]!; + } + Assert.That(counts.Count, Is.EqualTo(4), $"{label}: 4 distinct cats expected"); + foreach (var (kMod, name) in new[] { (0L, "a"), (1L, "b"), (2L, "c"), (3L, "d") }) + { + Assert.That(counts.TryGetValue(name, out var got), Is.True, $"{label}: cat={name} missing"); + Assert.That(got, Is.EqualTo(CatCount(totalRows, kMod)), $"{label}: cat={name} count-mismatch"); + } + break; + } + case 3: + { + var loRow = 1 + (long)rng.GenRange((ulong)Math.Max(1, totalRows - 2)); + var span = 1 + (long)rng.GenRange((ulong)Math.Max(1, totalRows - loRow)); + var tsLo = (loRow - 1) * spacing; + var tsHi = (loRow + span - 1) * spacing; + var rows = Query(client, + $"select id from \"{table}\" where ts >= CAST({tsLo}L AS TIMESTAMP) " + + $"and ts < CAST({tsHi}L AS TIMESTAMP)"); + Assert.That(rows.Count, Is.EqualTo(span), $"{label}: row_count drift"); + for (var r = 0; r < rows.Count; r++) + { + Assert.That(rows[r][0], Is.EqualTo(loRow + r), $"{label}: id-mismatch"); + } + break; + } + case 4: + { + var pickCount = 1 + (int)rng.GenRange((ulong)BaseCols.Length); + var shuffled = Enumerable.Range(0, BaseCols.Length).ToList(); + for (var i = shuffled.Count - 1; i > 0; i--) + { + var j = (int)rng.GenRange((ulong)(i + 1)); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + var cols = shuffled.Take(pickCount).Select(i => BaseCols[i]).ToArray(); + var rows = Query(client, $"select {string.Join(",", cols)} from \"{table}\" order by id"); + Assert.That(rows.Count, Is.EqualTo(totalRows), $"{label}: row_count drift"); + for (var r = 0; r < rows.Count; r++) + { + for (var c = 0; c < cols.Length; c++) + { + VerifyBaseCell(rows[r][c], cols[c], r + 1, spacing, $"shape=4 col={cols[c]}"); + } + } + break; + } + default: + { + var rows = Query(client, $"select * from \"{table}\"", out var columnCount); + Assert.That(columnCount, Is.EqualTo(BaseCols.Length + liveAdded.Length), + "shape=5: column count drift"); + Assert.That(rows.Count, Is.EqualTo(totalRows), "shape=5: row_count drift"); + for (var r = 0; r < rows.Count; r++) + { + for (var c = 0; c < BaseCols.Length; c++) + { + VerifyBaseCell(rows[r][c], BaseCols[c], r + 1, spacing, "shape=5 base"); + } + + for (var i = 0; i < liveAdded.Length; i++) + { + Assert.That(rows[r][BaseCols.Length + i], Is.Null, + $"shape=5: extra column {liveAdded[i]} row {r} expected NULL"); + } + } + break; + } + } + } + + private static void VerifyBaseCell(object? cell, string inCol, long id, long spacing, string label) + { + switch (inCol) + { + case "id": + Assert.That(cell, Is.EqualTo(id), $"{label}: id-mismatch"); + break; + case "v": + Assert.That(cell, Is.EqualTo(ExpectedV(id)), $"{label}: v-mismatch id={id}"); + break; + case "cat": + Assert.That(cell, Is.EqualTo(CatFor(id)), $"{label}: cat-mismatch id={id}"); + break; + case "ts": + Assert.That(cell, Is.EqualTo((id - 1) * spacing), $"{label}: ts-mismatch id={id}"); + break; + default: + Assert.Fail($"{label}: unexpected base column {inCol}"); + break; + } + } + + private static double ExpectedV(long id) => id * 1.5; + + private static string CatFor(long id) => (id % 4) switch { 0 => "a", 1 => "b", 2 => "c", _ => "d" }; + + private static long CatCount(long totalRows, long kMod) + => kMod == 0 ? totalRows / 4 : (totalRows + 4 - kMod) / 4; + + private static long PickSpacingMicros(SplitMix64 rng) + { + long[] choices = { 300_000_000, 864_000_000, 3_600_000_000, 21_600_000_000 }; + return choices[(int)rng.GenRange((ulong)choices.Length)]; + } + + private static string PickCompression(SplitMix64 rng) => rng.GenRange(5) switch + { + 0 => string.Empty, + 1 => "compression=raw", + 2 => "compression=auto", + 3 => "compression=zstd", + _ => $"compression=zstd;compression_level={1 + rng.GenRange(9)}", + }; + + private static List Query(IQwpQueryClient client, string sql) + => Query(client, sql, out _); + + private static List Query(IQwpQueryClient client, string sql, out int columnCount) + { + var handler = new CollectingHandler(); + client.Execute(sql, handler); + columnCount = handler.ColumnCount; + return handler.Rows; + } + + private sealed class CollectingHandler : QwpColumnBatchHandler + { + public List Rows { get; } = new(); + public int ColumnCount { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) + { + ColumnCount = batch.ColumnCount; + for (var r = 0; r < batch.RowCount; r++) + { + var row = new object?[batch.ColumnCount]; + for (var c = 0; c < batch.ColumnCount; c++) + { + row[c] = batch.IsNull(c, r) ? null : ExtractCell(batch, c, r); + } + Rows.Add(row); + } + } + + public override void OnError(QwpStatusCode status, string message) + => Assert.Fail($"unexpected egress error: status={status}, msg={message}"); + + private static object ExtractCell(QwpColumnBatch batch, int c, int r) => batch.GetColumnWireType(c) switch + { + QwpTypeCode.Long => batch.GetLongValue(c, r), + QwpTypeCode.Int => batch.GetIntValue(c, r), + QwpTypeCode.Double => batch.GetDoubleValue(c, r), + QwpTypeCode.Timestamp => batch.GetTimestampValue(c, r), + QwpTypeCode.TimestampNanos => batch.GetTimestampValue(c, r), + QwpTypeCode.Symbol => batch.GetSymbol(c, r) ?? string.Empty, + QwpTypeCode.Boolean => batch.GetBoolValue(c, r), + _ => batch.GetString(c, r) ?? string.Empty, + }; + } + + private static async Task ExecAsync(string httpEndpoint, string sql) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + using var resp = await client.GetAsync($"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}"); + Assert.That((int)resp.StatusCode, Is.InRange(200, 399), $"exec failed: {sql}"); + } + + private static async Task WaitForRowCountAsync(string httpEndpoint, string table, int expected) + => await PollAsync(httpEndpoint, $"select count() from \"{table}\"", + ds => ds.GetArrayLength() > 0 && ds[0][0].GetInt64() >= expected, + $"{table} did not reach {expected} rows"); + + private static async Task WaitForColumnCountAsync(string httpEndpoint, string table, int expected) + => await PollAsync(httpEndpoint, $"show columns from \"{table}\"", + ds => ds.GetArrayLength() == expected, + $"{table} did not reach {expected} columns"); + + private static async Task PollAsync(string httpEndpoint, string sql, Func done, string failMsg) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline) + { + try + { + var resp = await client.GetAsync($"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}"); + if (resp.IsSuccessStatusCode) + { + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (json.RootElement.TryGetProperty("dataset", out var ds) && done(ds)) + { + return; + } + } + } + catch + { + } + + await Task.Delay(80); + } + + Assert.Fail($"{failMsg} within 60s"); + } + + private static ulong SeedFor(string testName) + { + var raw = Environment.GetEnvironmentVariable("QWP_EGRESS_FUZZ_SEED"); + var baseSeed = DefaultSeed; + if (!string.IsNullOrWhiteSpace(raw)) + { + raw = raw.Trim(); + baseSeed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw.Substring(2), 16) + : ulong.Parse(raw); + } + + unchecked + { + var hash = 0xCBF2_9CE4_8422_2325UL; + foreach (var b in System.Text.Encoding.UTF8.GetBytes(testName)) + { + hash ^= b; + hash *= 0x100_0000_01B3UL; + } + var combined = baseSeed + hash; + TestContext.Progress.WriteLine($"[qwp_egress_fuzz seed] {testName} seed=0x{combined:x16}"); + return combined; + } + } + + private sealed class SplitMix64 + { + private ulong _state; + + public SplitMix64(ulong seed) => _state = seed | 0x9E37_79B9_7F4A_7C15UL; + + public ulong NextU64() + { + unchecked + { + _state += 0x9E37_79B9_7F4A_7C15UL; + var z = _state; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9UL; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EBUL; + return z ^ (z >> 31); + } + } + + public ulong GenRange(ulong bound) => NextU64() % bound; + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbEgressBindFuzzTests.cs b/src/net-questdb-client-tests/QuestDbEgressBindFuzzTests.cs new file mode 100644 index 00000000..13c5dc9c --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbEgressBindFuzzTests.cs @@ -0,0 +1,326 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_tests; + +/// Live-server egress bind-parameter fuzz; port of egress_live_server_bind_fuzz.rs. +[TestFixture] +public class QuestDbEgressBindFuzzTests +{ + private const int IlpPort = 19409; + private const int HttpPort = 19400; + private const int IterationsPerTest = 25; + private const ulong DefaultSeed = 0x5EED_1234_ABCD_0001UL; + + private QuestDbManager? _questDb; + + [OneTimeSetUp] + public async Task SetUpFixture() + { + _questDb = new QuestDbManager(IlpPort, HttpPort); + try + { + await _questDb.StartAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) + { + await _questDb.DisposeAsync(); + _questDb = null; + Assert.Ignore($"QWP egress bind fuzz needs a QuestDB master build: {ex.Message}"); + } + } + + [OneTimeTearDown] + public async Task TearDownFixture() + { + if (_questDb is not null) + { + await _questDb.DisposeAsync(); + } + } + + private IQwpQueryClient NewClient() + => QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};path={QwpConstants.ReadPath};target=any;"); + + [Test] + public void FuzzDoubleBinds() + { + var rng = new SplitMix64(SeedFor(nameof(FuzzDoubleBinds))); + using var client = NewClient(); + for (var iter = 0; iter < IterationsPerTest; iter++) + { + var v = PickSpecialOrRandomDouble(rng); + var handler = new SingleBatchHandler(); + client.Execute("SELECT $1::DOUBLE AS d FROM long_sequence(1)", + b => b.SetDouble(0, v), handler); + + if (double.IsNaN(v)) + { + var nanOrNull = handler.Batch!.IsNull(0, 0) || double.IsNaN(handler.Batch.GetDoubleValue(0, 0)); + Assert.That(nanOrNull, Is.True, + $"iter {iter}: NaN bind must round-trip as null or NaN (QuestDB DOUBLE null is NaN)"); + } + else + { + var got = handler.Batch!.GetDoubleValue(0, 0); + Assert.That(BitConverter.DoubleToInt64Bits(got), + Is.EqualTo(BitConverter.DoubleToInt64Bits(v)), $"iter {iter}: double bit-mismatch"); + } + } + } + + [Test] + public void FuzzIntegralBindsProjection() + { + var rng = new SplitMix64(SeedFor(nameof(FuzzIntegralBindsProjection))); + using var client = NewClient(); + for (var iter = 0; iter < IterationsPerTest; iter++) + { + var longVal = PickNonNullLong(rng); + var intVal = PickNonNullInt(rng); + var shortVal = unchecked((short)rng.NextI32()); + var byteVal = unchecked((byte)rng.NextI32()); + var boolVal = rng.NextBool(); + + var handler = new SingleBatchHandler(); + client.Execute( + "SELECT $1::LONG AS l, $2::INT AS i, $3::SHORT AS s, " + + "$4::BYTE AS b, $5::BOOLEAN AS x FROM long_sequence(1)", + binds => binds + .SetLong(0, longVal) + .SetInt(1, intVal) + .SetShort(2, shortVal) + .SetByte(3, byteVal) + .SetBoolean(4, boolVal), + handler); + + var batch = handler.Batch!; + Assert.That(batch.GetLongValue(0, 0), Is.EqualTo(longVal), $"iter {iter}: long"); + Assert.That(batch.GetIntValue(1, 0), Is.EqualTo(intVal), $"iter {iter}: int"); + Assert.That(batch.GetShortValue(2, 0), Is.EqualTo(shortVal), $"iter {iter}: short"); + Assert.That(batch.GetByteValue(3, 0), Is.EqualTo(byteVal), $"iter {iter}: byte"); + Assert.That(batch.GetBoolValue(4, 0), Is.EqualTo(boolVal), $"iter {iter}: bool"); + } + } + + [Test] + public async Task FuzzSameSqlDifferentBindsCacheReuse() + { + var table = $"egress_bind_fuzz_cache_{Guid.NewGuid():N}"; + var http = _questDb!.GetHttpEndpoint(); + await ExecAsync(http, + $"create table \"{table}\" (id LONG, v LONG, part_ts TIMESTAMP) " + + "timestamp(part_ts) partition by day wal"); + + var insert = new System.Text.StringBuilder($"insert into \"{table}\" values "); + for (var r = 0; r < 100; r++) + { + if (r > 0) insert.Append(','); + insert.Append($"({r}, {r * 7}, CAST({r + 1} AS TIMESTAMP))"); + } + await ExecAsync(http, insert.ToString()); + await WaitForRowsAsync(http, table, 100); + + var rng = new SplitMix64(SeedFor(nameof(FuzzSameSqlDifferentBindsCacheReuse))); + using var client = NewClient(); + var sql = $"SELECT v FROM \"{table}\" WHERE id = $1"; + for (var iter = 0; iter < 50; iter++) + { + var target = (int)rng.GenRangeU32(100); + var handler = new SingleBatchHandler(); + client.Execute(sql, b => b.SetInt(0, target), handler); + + Assert.That(handler.Batch!.RowCount, Is.EqualTo(1), $"iter {iter}: row_count"); + Assert.That(handler.Batch.GetLongValue(0, 0), Is.EqualTo((long)target * 7), + $"iter {iter}: target={target}"); + } + + await ExecAsync(http, $"drop table \"{table}\""); + } + + [Test] + public void FuzzUuidBinds() + { + var rng = new SplitMix64(SeedFor(nameof(FuzzUuidBinds))); + using var client = NewClient(); + for (var iter = 0; iter < IterationsPerTest; iter++) + { + var lo = unchecked((long)rng.NextU64()); + var hi = unchecked((long)rng.NextU64()); + var handler = new SingleBatchHandler(); + client.Execute("SELECT $1::UUID AS u FROM long_sequence(1)", + b => b.SetUuid(0, lo, hi), handler); + + Assert.That(handler.Batch!.GetUuidLo(0, 0), Is.EqualTo(lo), $"iter {iter}: uuid lo"); + Assert.That(handler.Batch.GetUuidHi(0, 0), Is.EqualTo(hi), $"iter {iter}: uuid hi"); + } + } + + private static long PickNonNullLong(SplitMix64 rng) + { + while (true) + { + var v = rng.NextI64(); + if (v != long.MinValue) return v; + } + } + + private static int PickNonNullInt(SplitMix64 rng) + { + while (true) + { + var v = rng.NextI32(); + if (v != int.MinValue) return v; + } + } + + private static double PickSpecialOrRandomDouble(SplitMix64 rng) + { + switch (rng.GenRangeU32(4)) + { + case 0: + return double.NaN; + case 1: + return 0.0; + default: + while (true) + { + var v = BitConverter.Int64BitsToDouble(unchecked((long)rng.NextU64())); + if (!double.IsInfinity(v)) return v; + } + } + } + + private static async Task ExecAsync(string httpEndpoint, string sql) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + using var resp = await client.GetAsync($"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}"); + Assert.That((int)resp.StatusCode, Is.InRange(200, 399), $"exec failed: {sql}"); + } + + private static async Task WaitForRowsAsync(string httpEndpoint, string table, int expected) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline) + { + try + { + var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"select count() from \"{table}\"")}"); + if (resp.IsSuccessStatusCode) + { + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (json.RootElement.TryGetProperty("dataset", out var ds) && ds.GetArrayLength() > 0 + && ds[0][0].GetInt64() >= expected) + { + return; + } + } + } + catch + { + } + + await Task.Delay(80); + } + + Assert.Fail($"{table} did not reach {expected} rows within 60s"); + } + + private static ulong SeedFor(string testName) + { + var raw = Environment.GetEnvironmentVariable("QWP_EGRESS_FUZZ_SEED"); + var baseSeed = DefaultSeed; + if (!string.IsNullOrWhiteSpace(raw)) + { + raw = raw.Trim(); + baseSeed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw.Substring(2), 16) + : ulong.Parse(raw); + } + + unchecked + { + var hash = 0xCBF2_9CE4_8422_2325UL; + foreach (var b in System.Text.Encoding.UTF8.GetBytes(testName)) + { + hash ^= b; + hash *= 0x100_0000_01B3UL; + } + var combined = baseSeed + hash; + TestContext.Progress.WriteLine($"[qwp_egress_fuzz seed] {testName} seed=0x{combined:x16}"); + return combined; + } + } + + private sealed class SingleBatchHandler : QwpColumnBatchHandler + { + public QwpColumnBatch? Batch { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) => Batch ??= batch; + + public override void OnError(QwpStatusCode status, string message) + => Assert.Fail($"unexpected egress error: status={status}, msg={message}"); + } + + private sealed class SplitMix64 + { + private ulong _state; + + public SplitMix64(ulong seed) => _state = seed | 0x9E37_79B9_7F4A_7C15UL; + + public ulong NextU64() + { + unchecked + { + _state += 0x9E37_79B9_7F4A_7C15UL; + var z = _state; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9UL; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EBUL; + return z ^ (z >> 31); + } + } + + public long NextI64() => unchecked((long)NextU64()); + + public int NextI32() => unchecked((int)NextU64()); + + public bool NextBool() => (NextU64() & 1) == 0; + + public uint GenRangeU32(uint bound) => (uint)(NextU64() % bound); + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbEgressFailoverTests.cs b/src/net-questdb-client-tests/QuestDbEgressFailoverTests.cs new file mode 100644 index 00000000..874c4039 --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbEgressFailoverTests.cs @@ -0,0 +1,300 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_tests; + +/// +/// Live two-server failover suite for the QWP egress reader; port of +/// test_egress_failover.py. Two managed QuestDB instances, seeded identically; servers +/// are SIGKILLed mid-query ( is a hard kill) to +/// exercise the reader's failover path. +/// +[TestFixture] +public class QuestDbEgressFailoverTests +{ + private const int RowCount = 1_000_000; + private const string Table = "failover_test"; + + private QuestDbManager? _server1; + private QuestDbManager? _server2; + + [OneTimeSetUp] + public async Task SetUpFixture() + { + _server1 = new QuestDbManager(19311, 19301); + _server2 = new QuestDbManager(19312, 19302); + + if (_server1.UseLiveServer) + { + await DisposeServersAsync(); + Assert.Ignore("egress failover tests need two managed QuestDB instances"); + } + + try + { + await _server1.StartAsync(); + await _server2.StartAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) + { + await DisposeServersAsync(); + Assert.Ignore($"egress failover needs a QuestDB master build: {ex.Message}"); + } + + await SeedAsync(_server1); + await SeedAsync(_server2); + } + + [OneTimeTearDown] + public async Task TearDownFixture() + { + await DisposeServersAsync(); + } + + [SetUp] + public async Task EnsureServersUp() + { + foreach (var srv in new[] { _server1!, _server2! }) + { + if (!srv.IsRunning) + { + await srv.StartAsync(); + await SeedAsync(srv); + } + } + } + + /// + /// Connect string lists an unreachable address first and a healthy one second. + /// must walk past the refused connect and run the + /// query against the healthy endpoint; the connect-time walk does not count as a failover. + /// + [Test] + public void InitialConnectWalksPastUnreachable() + { + var deadPort = ReserveClosedPort(); + var conf = $"ws::addr=127.0.0.1:{deadPort},127.0.0.1:{Server2HttpPort};"; + + var handler = new FailoverHandler(); + using var client = QueryClient.New(conf); + client.Execute("select 1", handler); + + Assert.That(handler.FailoverResets, Is.EqualTo(0), + "connect-time endpoint walk must not increment the failover counter"); + Assert.That(handler.TotalRows, Is.EqualTo(1)); + } + + /// + /// The cursor opens against server #1, consumes one batch, then server #1 is SIGKILLed. + /// The reader fails over to server #2, replays the query, and re-streams every row. + /// + [Test] + public void MidQueryFailover() + { + Assert.That(_server1!.IsRunning && _server2!.IsRunning, Is.True); + + var conf = $"ws::addr=127.0.0.1:{Server1HttpPort},127.0.0.1:{Server2HttpPort};"; + var handler = new FailoverHandler(onFirstBatch: () => Kill(_server1)); + + using var client = QueryClient.New(conf); + client.Execute($"SELECT * FROM {Table} ORDER BY val", handler); + + Assert.That(handler.FailoverResets, Is.GreaterThanOrEqualTo(1), + "no failover happened despite the mid-stream kill"); + Assert.That(handler.TotalRows, Is.EqualTo(RowCount), + "the replayed query against server #2 must deliver every row exactly once"); + } + + /// + /// With both endpoints dead and a tight failover budget, Execute must surface a + /// clean error rather than hang or crash — and the now-poisoned client must keep + /// surfacing clean errors on subsequent calls. + /// + [Test] + public void ReaderPoisonedAfterFailoverExhaustion() + { + Assert.That(_server1!.IsRunning && _server2!.IsRunning, Is.True); + + var conf = $"ws::addr=127.0.0.1:{Server1HttpPort},127.0.0.1:{Server2HttpPort};" + + "failover_max_attempts=1;failover_backoff_initial_ms=1;failover_backoff_max_ms=2;"; + var handler = new FailoverHandler(onFirstBatch: () => + { + Kill(_server1); + Kill(_server2); + }); + + using var client = QueryClient.New(conf); + Assert.Catch(() => client.Execute($"SELECT * FROM {Table} ORDER BY val", handler), + "failover exhaustion must throw, not hang or crash"); + Assert.Catch(() => client.Execute("select 1", new FailoverHandler()), + "a poisoned reader must keep surfacing a clean error"); + } + + /// + /// A single-address connect list: the failover rotation collapses to the same endpoint. + /// Once it dies, the budget must exhaust into a clean error rather than retrying forever. + /// + [Test] + public void SingleEndpointFailoverExhaustsBudget() + { + Assert.That(_server1!.IsRunning, Is.True); + + var conf = $"ws::addr=127.0.0.1:{Server1HttpPort};" + + "failover_max_attempts=2;failover_backoff_initial_ms=1;failover_backoff_max_ms=2;"; + var handler = new FailoverHandler(onFirstBatch: () => Kill(_server1)); + + using var client = QueryClient.New(conf); + Assert.Catch(() => client.Execute($"SELECT * FROM {Table} ORDER BY val", handler), + "single-endpoint exhaustion must throw, not retry indefinitely"); + Assert.Catch(() => client.Execute("select 1", new FailoverHandler()), + "a poisoned reader must keep surfacing a clean error"); + } + + private int Server1HttpPort => int.Parse(_server1!.GetHttpEndpoint().Split(':')[^1]); + private int Server2HttpPort => int.Parse(_server2!.GetHttpEndpoint().Split(':')[^1]); + + // Hard process kill (no graceful WS close) — the crash the failover path must engage on. + private static void Kill(QuestDbManager server) => server.StopAsync().GetAwaiter().GetResult(); + + private async Task DisposeServersAsync() + { + if (_server1 is not null) + { + await _server1.DisposeAsync(); + _server1 = null; + } + + if (_server2 is not null) + { + await _server2.DisposeAsync(); + _server2 = null; + } + } + + private static async Task SeedAsync(QuestDbManager server) + { + var endpoint = server.GetHttpEndpoint(); + await ExecAsync(endpoint, $"DROP TABLE IF EXISTS {Table}"); + // BYPASS WAL keeps the CTAS synchronous so the row count is correct on first read. + await ExecAsync(endpoint, + $"CREATE TABLE {Table} AS (" + + $"SELECT cast(x*1000 AS timestamp) ts, x val FROM long_sequence({RowCount})" + + ") TIMESTAMP(ts) PARTITION BY DAY BYPASS WAL"); + + var deadline = DateTime.UtcNow.AddSeconds(60); + long last = -1; + while (DateTime.UtcNow < deadline) + { + last = await CountAsync(endpoint); + if (last == RowCount) + { + return; + } + + await Task.Delay(200); + } + + Assert.Fail($"seed timed out for {Table} on {endpoint}; last count {last}"); + } + + private static async Task ExecAsync(string httpEndpoint, string sql) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + using var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}"); + resp.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (json.RootElement.TryGetProperty("error", out var err)) + { + throw new InvalidOperationException($"exec failed: {err.GetString()}"); + } + } + + private static async Task CountAsync(string httpEndpoint) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + using var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"SELECT count(*) FROM {Table}")}"); + if (!resp.IsSuccessStatusCode) + { + return -1; + } + + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + return json.RootElement.TryGetProperty("dataset", out var ds) && ds.GetArrayLength() > 0 + ? ds[0][0].GetInt64() + : -1; + } + + private static int ReserveClosedPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private sealed class FailoverHandler : QwpColumnBatchHandler + { + private readonly Action? _onFirstBatch; + private bool _firstBatchSeen; + + public FailoverHandler(Action? onFirstBatch = null) => _onFirstBatch = onFirstBatch; + + public long TotalRows { get; private set; } + + public int FailoverResets { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) + { + if (!_firstBatchSeen) + { + _firstBatchSeen = true; + _onFirstBatch?.Invoke(); + } + + TotalRows += batch.RowCount; + } + + public override void OnFailoverReset(QwpServerInfo? newNode) + { + FailoverResets++; + // The query replays from batch 0 on the new endpoint; drop rows counted pre-failover. + TotalRows = 0; + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbEgressFragmentationFuzzTests.cs b/src/net-questdb-client-tests/QuestDbEgressFragmentationFuzzTests.cs new file mode 100644 index 00000000..1097e06d --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbEgressFragmentationFuzzTests.cs @@ -0,0 +1,268 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_tests; + +/// Live-server egress fragmentation fuzz; port of egress_live_server_fragmentation_fuzz.rs. +[TestFixture] +public class QuestDbEgressFragmentationFuzzTests +{ + private const ulong DefaultSeed = 0x5EED_1234_ABCD_0001UL; + + [Test] + public Task FragmentedBackToBackQueries() => RunAsync( + httpPort: 19300, ilpPort: 19309, testName: nameof(FragmentedBackToBackQueries), + async (srv, http) => + { + var chunk = PickChunk(SeedFor(nameof(FragmentedBackToBackQueries))); + TestContext.Progress.WriteLine($"FragmentedBackToBackQueries chunk={chunk}"); + await ExecAsync(http, "drop table if exists btb"); + await ExecAsync(http, "create table btb(id LONG, v DOUBLE, ts TIMESTAMP) " + + "timestamp(ts) partition by day wal"); + await ExecAsync(http, "insert into btb select x, CAST(x * 2.5 AS DOUBLE), x::TIMESTAMP " + + "from long_sequence(8000)"); + await WaitForRowsAsync(http, "btb", 8000); + + using var client = QueryClient.New(EgressConn(srv)); + for (var q = 0; q < 5; q++) + { + var (rows, idSum) = SumId(client, "SELECT * FROM btb"); + Assert.That(rows, Is.EqualTo(8000), $"q={q} row_count drift"); + Assert.That(idSum, Is.EqualTo(ExpectedSum(8000)), $"q={q} id_sum drift"); + } + }); + + [Test] + public Task FragmentedCreditFlow() => RunAsync( + httpPort: 19310, ilpPort: 19319, testName: nameof(FragmentedCreditFlow), + async (srv, http) => + { + var chunk = PickChunk(SeedFor(nameof(FragmentedCreditFlow))); + TestContext.Progress.WriteLine($"FragmentedCreditFlow chunk={chunk}"); + await ExecAsync(http, "drop table if exists cf"); + await ExecAsync(http, "create table cf as ( " + + "select x as id, x::TIMESTAMP as ts from long_sequence(20000) " + + ") timestamp(ts) partition by day wal"); + await WaitForRowsAsync(http, "cf", 20000); + + using var client = QueryClient.New(new QueryOptions(EgressConn(srv)) { initial_credit = 2048 }); + var (rows, idSum) = SumId(client, "SELECT * FROM cf"); + Assert.That(rows, Is.EqualTo(20000), "row_count"); + Assert.That(idSum, Is.EqualTo(ExpectedSum(20000)), "id_sum"); + }); + + [Test] + public Task FragmentedStreamingBigResult() => RunAsync( + httpPort: 19320, ilpPort: 19329, testName: nameof(FragmentedStreamingBigResult), + async (srv, http) => + { + var chunk = PickChunk(SeedFor(nameof(FragmentedStreamingBigResult))); + TestContext.Progress.WriteLine($"FragmentedStreamingBigResult chunk={chunk}"); + await ExecAsync(http, "drop table if exists bigt"); + await ExecAsync(http, "create table bigt as ( " + + "select x as id, CAST(x * 1.5 AS DOUBLE) as v, " + + "CAST('s_' || (x % 100) AS SYMBOL) as s, x::TIMESTAMP as ts " + + "from long_sequence(50000) " + + ") timestamp(ts) partition by day wal"); + await WaitForRowsAsync(http, "bigt", 50000); + + using var client = QueryClient.New(EgressConn(srv)); + var (rows, idSum) = SumId(client, "SELECT * FROM bigt"); + Assert.That(rows, Is.EqualTo(50000), "row_count"); + Assert.That(idSum, Is.EqualTo(ExpectedSum(50000)), "id_sum"); + }); + + [Test] + public Task HandshakeSurvivesMicroChunk() => RunAsync( + httpPort: 19330, ilpPort: 19339, testName: nameof(HandshakeSurvivesMicroChunk), + async (srv, http) => + { + TestContext.Progress.WriteLine("HandshakeSurvivesMicroChunk chunk=5 (pinned)"); + await ExecAsync(http, "drop table if exists tiny"); + await ExecAsync(http, "create table tiny(id LONG, ts TIMESTAMP) timestamp(ts) partition by day wal"); + await ExecAsync(http, "insert into tiny select x, x::TIMESTAMP from long_sequence(3)"); + await WaitForRowsAsync(http, "tiny", 3); + + using var client = QueryClient.New(EgressConn(srv)); + var (rows, idSum) = SumId(client, "SELECT * FROM tiny"); + Assert.That(rows, Is.EqualTo(3), "row_count"); + Assert.That(idSum, Is.EqualTo(ExpectedSum(3)), "id_sum"); + }, fixedChunk: 5); + + private static async Task RunAsync( + int httpPort, int ilpPort, string testName, Func body, int? fixedChunk = null) + { + var chunk = fixedChunk ?? PickChunk(SeedFor(testName)); + var chunkStr = chunk.ToString(System.Globalization.CultureInfo.InvariantCulture); + var server = new QuestDbManager(ilpPort, httpPort, new[] + { + $"debug.http.force.recv.fragmentation.chunk.size={chunkStr}", + $"debug.http.force.send.fragmentation.chunk.size={chunkStr}", + }); + + try + { + await server.StartAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) + { + await server.DisposeAsync(); + Assert.Ignore($"QWP egress fragmentation fuzz needs a QuestDB master build: {ex.Message}"); + return; + } + + try + { + await body(server, server.GetHttpEndpoint()); + } + finally + { + await server.DisposeAsync(); + } + } + + private static string EgressConn(QuestDbManager srv, string extra = "") + => $"ws::addr={srv.GetWebSocketEndpoint()};path={QwpConstants.ReadPath};target=any;{extra}"; + + private static (int RowCount, long IdSum) SumId(IQwpQueryClient client, string sql) + { + var handler = new SumIdHandler(); + client.Execute(sql, handler); + return (handler.RowCount, handler.IdSum); + } + + private static long ExpectedSum(int n) => (long)n * (n + 1) / 2; + + private static int PickChunk(ulong seed) + { + unchecked + { + var z = seed + 0x9E37_79B9_7F4A_7C15UL; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9UL; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EBUL; + z ^= z >> 31; + return 1 + (int)(z % 500); + } + } + + private static ulong SeedFor(string testName) + { + var raw = Environment.GetEnvironmentVariable("QWP_EGRESS_FUZZ_SEED"); + var baseSeed = DefaultSeed; + if (!string.IsNullOrWhiteSpace(raw)) + { + raw = raw.Trim(); + baseSeed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw.Substring(2), 16) + : ulong.Parse(raw); + } + + unchecked + { + var hash = 0xCBF2_9CE4_8422_2325UL; + foreach (var b in System.Text.Encoding.UTF8.GetBytes(testName)) + { + hash ^= b; + hash *= 0x100_0000_01B3UL; + } + var combined = baseSeed + hash; + TestContext.Progress.WriteLine($"[qwp_egress_fuzz seed] {testName} seed=0x{combined:x16}"); + return combined; + } + } + + private static async Task ExecAsync(string httpEndpoint, string sql) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + using var resp = await client.GetAsync($"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}"); + Assert.That((int)resp.StatusCode, Is.InRange(200, 399), $"exec failed ({(int)resp.StatusCode}): {sql}"); + } + + private static async Task WaitForRowsAsync(string httpEndpoint, string table, int expected) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var deadline = DateTime.UtcNow.AddSeconds(90); + long last = -1; + while (DateTime.UtcNow < deadline) + { + try + { + var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"select count() from \"{table}\"")}"); + if (resp.IsSuccessStatusCode) + { + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (json.RootElement.TryGetProperty("dataset", out var ds) && ds.GetArrayLength() > 0) + { + last = ds[0][0].GetInt64(); + if (last >= expected) + { + return; + } + } + } + } + catch + { + } + + await Task.Delay(80); + } + + Assert.Fail($"{table} did not reach {expected} rows within 90s; last count={last}"); + } + + private sealed class SumIdHandler : QwpColumnBatchHandler + { + public int RowCount { get; private set; } + public long IdSum { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) + { + for (var r = 0; r < batch.RowCount; r++) + { + if (!batch.IsNull(0, r)) + { + IdSum = unchecked(IdSum + batch.GetLongValue(0, r)); + } + } + RowCount += batch.RowCount; + } + + public override void OnError(QwpStatusCode status, string message) + => Assert.Fail($"unexpected egress error: status={status}, msg={message}"); + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbEgressFuzzTests.cs b/src/net-questdb-client-tests/QuestDbEgressFuzzTests.cs new file mode 100644 index 00000000..6671d55d --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbEgressFuzzTests.cs @@ -0,0 +1,706 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Globalization; +using System.Text; +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Senders; + +namespace net_questdb_client_tests; + +/// +/// Live-server egress random-schema fuzz with a per-cell hash oracle; port of +/// egress_live_server_fuzz.rs. +/// +[TestFixture] +public class QuestDbEgressFuzzTests +{ + private const ulong DefaultSeed = 0xB39C_4F7E_2A85_91D2UL; + + [Test] + public Task RandomSchemaRoundtrip() => RunAsync( + httpPort: 19600, ilpPort: 19609, testName: nameof(RandomSchemaRoundtrip), + (server, rng, compression) => + { + var generators = BuildGenerators(); + for (var iter = 0; iter < 15; iter++) + { + var colCount = 1 + (int)rng.GenRange(6); + using var client = QueryClient.New(EgressConn(server, compression)); + RunOneCase(server, client, rng, generators, "fuzz_iter", iter, colCount); + } + return Task.CompletedTask; + }); + + [Test] + public Task BackToBackQueriesSameConnection() => RunAsync( + httpPort: 19610, ilpPort: 19619, testName: nameof(BackToBackQueriesSameConnection), + (server, rng, compression) => + { + var generators = BuildGenerators(); + using var client = QueryClient.New(EgressConn(server, compression)); + for (var iter = 0; iter < 12; iter++) + { + var colCount = 1 + (int)rng.GenRange(4); + RunOneCase(server, client, rng, generators, "fuzz_back", iter, colCount); + } + return Task.CompletedTask; + }); + + [Test] + public Task WideTables() => RunAsync( + httpPort: 19620, ilpPort: 19629, testName: nameof(WideTables), + (server, rng, compression) => + { + var generators = BuildGenerators(); + using var client = QueryClient.New(EgressConn(server, compression)); + var colCount = 10 + (int)rng.GenRange(7); + RunOneCase(server, client, rng, generators, "fuzz_wide", 0, colCount); + return Task.CompletedTask; + }); + + private static async Task RunAsync( + int httpPort, int ilpPort, string testName, Func body) + { + var rng = new SplitMix64(SeedFor(testName)); + var chunk = 1 + (int)rng.GenRange(500); + var compression = PickCompression(rng); + TestContext.Progress.WriteLine($"{testName} chunk={chunk} compression={compression}"); + + var chunkStr = chunk.ToString(CultureInfo.InvariantCulture); + var server = new QuestDbManager(ilpPort, httpPort, new[] + { + $"debug.http.force.recv.fragmentation.chunk.size={chunkStr}", + $"debug.http.force.send.fragmentation.chunk.size={chunkStr}", + }); + + try + { + await server.StartAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) + { + await server.DisposeAsync(); + Assert.Ignore($"QWP egress fuzz needs a QuestDB master build: {ex.Message}"); + return; + } + + try + { + await body(server, rng, compression); + } + finally + { + await server.DisposeAsync(); + } + } + + private static string EgressConn(QuestDbManager srv, string compression) + => $"ws::addr={srv.GetWebSocketEndpoint()};path={QwpConstants.ReadPath};target=any;" + + (compression.Length > 0 ? compression + ";" : string.Empty); + + private static void RunOneCase( + QuestDbManager server, IQwpQueryClient client, SplitMix64 rng, + ColumnGenerator[] generators, string tableStem, int iter, int colCount) + { + var http = server.GetHttpEndpoint(); + + var picked = new ColumnGenerator[colCount]; + for (var i = 0; i < colCount; i++) + { + picked[i] = generators[(int)rng.GenRange((ulong)generators.Length)]; + } + + var rowCount = PickRowCount(rng); + var table = $"{tableStem}_{iter}"; + + Exec(http, $"drop table if exists \"{table}\""); + var create = new StringBuilder($"create table \"{table}\" (id LONG, ts TIMESTAMP"); + for (var i = 0; i < colCount; i++) + { + create.Append($", c{i} {picked[i].SqlType}"); + } + create.Append(") timestamp(ts) partition by day wal"); + Exec(http, create.ToString()); + + var expectedHash = new long[rowCount][]; + var expectedNull = new bool[rowCount][]; + var valuesClauses = new List(rowCount); + for (var r = 0; r < rowCount; r++) + { + expectedHash[r] = new long[colCount]; + expectedNull[r] = new bool[colCount]; + var id = r + 1L; + var ts = id * 1000; + var lits = new List(colCount + 2) { $"{id}L", $"CAST({ts} AS TIMESTAMP)" }; + for (var c = 0; c < colCount; c++) + { + var gen = picked[c]; + var forceNull = gen.SupportsNull && rng.GenRange(5) == 0; + if (forceNull) + { + expectedNull[r][c] = true; + lits.Add($"CAST(NULL AS {gen.SqlType})"); + } + else + { + var cell = gen.RandomValue(rng); + expectedHash[r][c] = cell.Hash; + lits.Add(cell.Literal); + } + } + valuesClauses.Add($"({string.Join(",", lits)})"); + } + + ExecInsert(http, table, valuesClauses); + WaitForRows(http, table, rowCount); + + var plan = PlanQuery(rng, table, colCount, rowCount, iter); + var expectedRows = plan.LastRowId - plan.FirstRowId + 1; + + var handler = new HashCollectingHandler(picked, plan.ProjMap); + client.Execute(plan.Sql, handler); + + Assert.That(handler.Rows.Count, Is.EqualTo(expectedRows), + $"iter={iter} shape={plan.Shape} row_count drift"); + + for (var outRow = 0; outRow < handler.Rows.Count; outRow++) + { + var id = plan.Descending ? plan.LastRowId - outRow : plan.FirstRowId + outRow; + var inputRow = id - 1; + var cells = handler.Rows[outRow]; + for (var outC = 0; outC < plan.ProjMap.Length; outC++) + { + var inC = plan.ProjMap[outC]; + Assert.That(cells[outC].IsNull, Is.EqualTo(expectedNull[inputRow][inC]), + $"iter={iter} shape={plan.Shape} row={inputRow} outC={outC} inC={inC} null-mismatch"); + if (!cells[outC].IsNull) + { + Assert.That(cells[outC].Hash, Is.EqualTo(expectedHash[inputRow][inC]), + $"iter={iter} shape={plan.Shape} row={inputRow} outC={outC} inC={inC} " + + $"type={picked[inC].SqlType} hash-mismatch"); + } + } + } + + Exec(http, $"drop table \"{table}\""); + } + + private sealed class Plan + { + public string Sql = string.Empty; + public int Shape; + public bool Descending; + public int FirstRowId; + public int LastRowId; + public int[] ProjMap = Array.Empty(); + } + + private static Plan PlanQuery(SplitMix64 rng, string table, int colCount, int rowCount, int iter) + { + var shape = rowCount < 4 ? 0 : iter % 4; + var identity = Enumerable.Range(0, colCount).ToArray(); + string Proj(int[] cols) => string.Join(",", cols.Select(c => $"c{c}")); + + switch (shape) + { + case 0: + return new Plan + { + Sql = $"select {Proj(identity)} from \"{table}\" order by id", + Shape = 0, Descending = false, FirstRowId = 1, LastRowId = rowCount, ProjMap = identity, + }; + case 1: + { + var pickCount = 1 + (int)rng.GenRange((ulong)colCount); + var shuffled = (int[])identity.Clone(); + for (var i = colCount - 1; i > 0; i--) + { + var j = (int)rng.GenRange((ulong)(i + 1)); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + var subset = shuffled.Take(pickCount).ToArray(); + return new Plan + { + Sql = $"select {Proj(subset)} from \"{table}\" order by id", + Shape = 1, Descending = false, FirstRowId = 1, LastRowId = rowCount, ProjMap = subset, + }; + } + case 2: + { + var lo = 1 + (int)rng.GenRange((ulong)(rowCount - 1)); + var maxSpan = rowCount - lo; + var span = maxSpan == 0 ? 0 : (int)rng.GenRange((ulong)(maxSpan + 1)); + var hi = lo + span; + return new Plan + { + Sql = $"select {Proj(identity)} from \"{table}\" where id >= {lo} and id <= {hi} order by id", + Shape = 2, Descending = false, FirstRowId = lo, LastRowId = hi, ProjMap = identity, + }; + } + default: + { + var limit = 1 + (int)rng.GenRange((ulong)rowCount); + return new Plan + { + Sql = $"select {Proj(identity)} from \"{table}\" order by id desc limit {limit}", + Shape = 3, Descending = true, + FirstRowId = rowCount - limit + 1, LastRowId = rowCount, ProjMap = identity, + }; + } + } + } + + private readonly record struct CellGen(string Literal, long Hash); + + private sealed class ColumnGenerator + { + public required string SqlType { get; init; } + public bool SupportsNull { get; init; } = true; + public required Func RandomValue { get; init; } + public required Func ObservedHash { get; init; } + } + + private static ColumnGenerator[] BuildGenerators() + { + var loPool = Enumerable.Range(0, 8).Select(i => $"s_lo_{i}").ToArray(); + var hiPool = Enumerable.Range(0, 1000).Select(i => $"s_hi_{i}").ToArray(); + + return new[] + { + new ColumnGenerator + { + SqlType = "LONG", + RandomValue = rng => + { + long v; + do { v = rng.NextI64(); } while (v == long.MinValue); + return new CellGen($"{v}L", v); + }, + ObservedHash = (b, c, r) => b.GetLongValue(c, r), + }, + new ColumnGenerator + { + SqlType = "INT", + RandomValue = rng => + { + int v; + do { v = rng.NextI32(); } while (v == int.MinValue); + return new CellGen(v.ToString(CultureInfo.InvariantCulture), v); + }, + ObservedHash = (b, c, r) => b.GetIntValue(c, r), + }, + new ColumnGenerator + { + SqlType = "SHORT", SupportsNull = false, + RandomValue = rng => + { + var v = (short)((int)rng.GenRange(65535) - 32767); + return new CellGen($"CAST({v} AS SHORT)", v); + }, + ObservedHash = (b, c, r) => b.GetShortValue(c, r), + }, + new ColumnGenerator + { + SqlType = "BYTE", SupportsNull = false, + RandomValue = rng => + { + var v = (sbyte)((int)rng.GenRange(255) - 127); + return new CellGen($"CAST({v} AS BYTE)", v); + }, + ObservedHash = (b, c, r) => b.GetSByteValue(c, r), + }, + new ColumnGenerator + { + SqlType = "BOOLEAN", SupportsNull = false, + RandomValue = rng => + { + var v = rng.NextBool(); + return new CellGen(v ? "true" : "false", v ? 1 : 0); + }, + ObservedHash = (b, c, r) => b.GetBoolValue(c, r) ? 1 : 0, + }, + new ColumnGenerator + { + SqlType = "DOUBLE", + RandomValue = rng => + { + double v; + do { v = rng.NextF64(); } while (!double.IsFinite(v)); + return new CellGen(FormatDoubleLiteral(v), BitConverter.DoubleToInt64Bits(v)); + }, + ObservedHash = (b, c, r) => BitConverter.DoubleToInt64Bits(b.GetDoubleValue(c, r)), + }, + new ColumnGenerator + { + SqlType = "FLOAT", + RandomValue = rng => + { + float v; + do { v = rng.NextF32(); } while (!float.IsFinite(v)); + return new CellGen($"CAST({FormatFloatLiteral(v)} AS FLOAT)", + (uint)BitConverter.SingleToInt32Bits(v)); + }, + ObservedHash = (b, c, r) => (uint)BitConverter.SingleToInt32Bits(b.GetFloatValue(c, r)), + }, + new ColumnGenerator + { + SqlType = "CHAR", SupportsNull = false, + RandomValue = rng => + { + var ch = (char)('A' + (int)rng.GenRange(26)); + return new CellGen($"'{ch}'", ch); + }, + ObservedHash = (b, c, r) => b.GetCharValue(c, r), + }, + new ColumnGenerator + { + SqlType = "TIMESTAMP", + RandomValue = rng => + { + var v = rng.NextI64() & 0x0FFF_FFFF_FFFF_FFFFL; + return new CellGen($"CAST({v} AS TIMESTAMP)", v); + }, + ObservedHash = (b, c, r) => b.GetTimestampValue(c, r), + }, + new ColumnGenerator + { + SqlType = "TIMESTAMP_NS", + RandomValue = rng => + { + var v = rng.NextI64() & 0x0FFF_FFFF_FFFF_FFFFL; + return new CellGen($"CAST({v} AS TIMESTAMP_NS)", v); + }, + ObservedHash = (b, c, r) => b.GetTimestampValue(c, r), + }, + new ColumnGenerator + { + SqlType = "DATE", + RandomValue = rng => + { + var v = rng.NextI64() & 0x0000_FFFF_FFFF_FFFFL; + return new CellGen($"CAST({v} AS DATE)", v); + }, + ObservedHash = (b, c, r) => b.GetDateValue(c, r), + }, + new ColumnGenerator + { + SqlType = "VARCHAR", + RandomValue = rng => + { + var s = RandomAsciiString(rng, (int)rng.GenRange(30)); + return new CellGen($"CAST('{s.Replace("'", "''")}' AS VARCHAR)", HashBytes(s)); + }, + ObservedHash = (b, c, r) => HashBytes(b.GetString(c, r) ?? string.Empty), + }, + SymbolGenerator(loPool), + SymbolGenerator(hiPool), + new ColumnGenerator + { + SqlType = "UUID", + RandomValue = rng => + { + var lo = rng.NextU64(); + var hi = rng.NextU64(); + if (lo == unchecked((ulong)long.MinValue) && hi == unchecked((ulong)long.MinValue)) + { + lo = 0; + } + var uuid = $"{hi:x16}".Insert(12, "-").Insert(8, "-") + + "-" + $"{lo:x16}".Insert(4, "-"); + return new CellGen($"CAST('{uuid}' AS UUID)", unchecked((long)(hi ^ lo))); + }, + ObservedHash = (b, c, r) => b.GetUuidHi(c, r) ^ b.GetUuidLo(c, r), + }, + new ColumnGenerator + { + SqlType = "LONG256", + RandomValue = rng => + { + var w0 = rng.NextU64(); + var w1 = rng.NextU64(); + var w2 = rng.NextU64(); + var w3 = rng.NextU64(); + var hex = $"0x{w3:x16}{w2:x16}{w1:x16}{w0:x16}"; + return new CellGen(hex, unchecked((long)(w0 ^ w1 ^ w2 ^ w3))); + }, + ObservedHash = (b, c, r) => + { + b.GetLong256(c, r, out var w0, out var w1, out var w2, out var w3); + return w0 ^ w1 ^ w2 ^ w3; + }, + }, + new ColumnGenerator + { + SqlType = "IPV4", + RandomValue = rng => + { + var a = 1 + (uint)rng.GenRange(254); + var b = 1 + (uint)rng.GenRange(254); + var c = 1 + (uint)rng.GenRange(254); + var d = 1 + (uint)rng.GenRange(254); + var packed = (a << 24) | (b << 16) | (c << 8) | d; + return new CellGen($"CAST('{a}.{b}.{c}.{d}' AS IPV4)", packed); + }, + ObservedHash = (b, c, r) => (uint)b.GetIPv4Value(c, r), + }, + }; + } + + private static ColumnGenerator SymbolGenerator(string[] pool) => new() + { + SqlType = "SYMBOL", + RandomValue = rng => + { + var s = pool[(int)rng.GenRange((ulong)pool.Length)]; + return new CellGen($"CAST('{s}' AS SYMBOL)", HashBytes(s)); + }, + ObservedHash = (b, c, r) => HashBytes(b.GetSymbol(c, r) ?? string.Empty), + }; + + private static long HashBytes(string s) => HashBytes(Encoding.UTF8.GetBytes(s)); + + private static long HashBytes(byte[] bytes) + { + var h = 1_125_899_906_842_597UL; + foreach (var b in bytes) + { + h = unchecked(h * 31 + b); + } + return unchecked((long)(h ^ (ulong)bytes.Length)); + } + + private static string RandomAsciiString(SplitMix64 rng, int len) + { + var chars = new char[len]; + for (var i = 0; i < len; i++) + { + var c = (byte)(0x20 + rng.GenRange(0x7E - 0x20)); + if (c == 0x27) c = 0x20; + chars[i] = (char)c; + } + return new string(chars); + } + + private static string FormatDoubleLiteral(double v) + { + var s = v.ToString("R", CultureInfo.InvariantCulture); + return s.IndexOfAny(new[] { '.', 'e', 'E' }) < 0 ? s + ".0" : s; + } + + private static string FormatFloatLiteral(float v) + { + var s = v.ToString("R", CultureInfo.InvariantCulture); + return s.IndexOfAny(new[] { '.', 'e', 'E' }) < 0 ? s + ".0" : s; + } + + private static int PickRowCount(SplitMix64 rng) + { + int[] choices = { 1, 2, 7, 64, 257, 499, 500 }; + return choices[(int)rng.GenRange((ulong)choices.Length)]; + } + + private static string PickCompression(SplitMix64 rng) => rng.GenRange(5) switch + { + 0 => string.Empty, + 1 => "compression=raw", + 2 => "compression=auto", + 3 => "compression=zstd", + _ => $"compression=zstd;compression_level={1 + rng.GenRange(9)}", + }; + + private static void Exec(string httpEndpoint, string sql) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; + using var resp = client.GetAsync($"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}") + .GetAwaiter().GetResult(); + Assert.That((int)resp.StatusCode, Is.InRange(200, 399), + $"exec failed ({(int)resp.StatusCode}): {(sql.Length > 200 ? sql.Substring(0, 200) + "…" : sql)}"); + } + + // QuestDB /exec only reads the query from the request URL, so one giant multi-row INSERT + // overflows the URL length limit. Split the rows into length-bounded batches. + private static void ExecInsert(string httpEndpoint, string table, List valuesClauses) + { + const int maxBatchSqlLen = 12 * 1024; + var batch = new List(); + var batchLen = 0; + foreach (var clause in valuesClauses) + { + if (batch.Count > 0 && batchLen + clause.Length + 1 > maxBatchSqlLen) + { + Exec(httpEndpoint, $"insert into \"{table}\" values {string.Join(",", batch)}"); + batch.Clear(); + batchLen = 0; + } + + batch.Add(clause); + batchLen += clause.Length + 1; + } + + if (batch.Count > 0) + { + Exec(httpEndpoint, $"insert into \"{table}\" values {string.Join(",", batch)}"); + } + } + + private static void WaitForRows(string httpEndpoint, string table, int expected) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline) + { + try + { + var resp = client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"select count() from \"{table}\"")}") + .GetAwaiter().GetResult(); + if (resp.IsSuccessStatusCode) + { + using var json = JsonDocument.Parse(resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + if (json.RootElement.TryGetProperty("dataset", out var ds) && ds.GetArrayLength() > 0 + && ds[0][0].GetInt64() >= expected) + { + return; + } + } + } + catch + { + } + + Thread.Sleep(80); + } + + Assert.Fail($"{table} did not reach {expected} rows within 60s"); + } + + private static ulong SeedFor(string testName) + { + var raw = Environment.GetEnvironmentVariable("QWP_EGRESS_FUZZ_SEED"); + var baseSeed = DefaultSeed; + if (!string.IsNullOrWhiteSpace(raw)) + { + raw = raw.Trim(); + baseSeed = raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw.Substring(2), 16) + : ulong.Parse(raw); + } + + unchecked + { + var hash = 0xCBF2_9CE4_8422_2325UL; + foreach (var b in Encoding.UTF8.GetBytes(testName)) + { + hash ^= b; + hash *= 0x100_0000_01B3UL; + } + var combined = baseSeed + hash; + TestContext.Progress.WriteLine($"[qwp_egress_fuzz seed] {testName} seed=0x{combined:x16}"); + return combined; + } + } + + private readonly record struct CellResult(bool IsNull, long Hash); + + private sealed class HashCollectingHandler : QwpColumnBatchHandler + { + private readonly ColumnGenerator[] _picked; + private readonly int[] _projMap; + + public HashCollectingHandler(ColumnGenerator[] picked, int[] projMap) + { + _picked = picked; + _projMap = projMap; + } + + public List Rows { get; } = new(); + + public override void OnBatch(QwpColumnBatch batch) + { + for (var r = 0; r < batch.RowCount; r++) + { + var cells = new CellResult[_projMap.Length]; + for (var outC = 0; outC < _projMap.Length; outC++) + { + var isNull = batch.IsNull(outC, r); + var hash = isNull ? 0L : _picked[_projMap[outC]].ObservedHash(batch, outC, r); + cells[outC] = new CellResult(isNull, hash); + } + Rows.Add(cells); + } + } + + public override void OnError(QwpStatusCode status, string message) + => Assert.Fail($"unexpected egress error: status={status}, msg={message}"); + } + + public sealed class SplitMix64 + { + private ulong _state; + + public SplitMix64(ulong seed) => _state = seed | 0x9E37_79B9_7F4A_7C15UL; + + public ulong NextU64() + { + unchecked + { + _state += 0x9E37_79B9_7F4A_7C15UL; + var z = _state; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9UL; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EBUL; + return z ^ (z >> 31); + } + } + + public long NextI64() => unchecked((long)NextU64()); + + public int NextI32() => unchecked((int)NextU64()); + + public bool NextBool() => (NextU64() & 1) == 0; + + public double NextF64() + { + var raw = (NextU64() >> 11) / (double)(1UL << 53); + return (raw - 0.5) * 1e9; + } + + public float NextF32() + { + var raw = (NextU64() >> 40) / (float)(1U << 24); + return (raw - 0.5f) * 1e5f; + } + + public ulong GenRange(ulong bound) => NextU64() % bound; + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs index e00c939c..46d021c5 100644 --- a/src/net-questdb-client-tests/QuestDbManager.cs +++ b/src/net-questdb-client-tests/QuestDbManager.cs @@ -1,307 +1,439 @@ using System.Diagnostics; +using System.Net.Sockets; +using System.Text; namespace net_questdb_client_tests; /// -/// Manages QuestDB server lifecycle for integration tests using Docker. -/// Handles pulling, starting, and stopping QuestDB container instances. +/// Manages a QuestDB server for integration tests by launching it as a local process, +/// the same way the Rust client's system_test/fixture.py does — no Docker. /// +/// +/// The QuestDB build is located via environment variables (checked in order): +/// +/// QDB_LIVE_HTTP (+ optional QDB_LIVE_ILP) — skip launching and +/// point the tests at an already-running instance. +/// QUESTDB_JAR — absolute path to a built questdb.jar. +/// QUESTDB_REPO — path to a built QuestDB repo; the fixture finds +/// core/target/**/questdb*-SNAPSHOT.jar. +/// +/// java is resolved from JAVA_HOME or PATH. The WS (/write/v4) +/// and egress (/read/v1) endpoints only exist on QuestDB master, so the jar must be +/// built from master (CI clones + builds it; see ci/azure-pipelines.yml). +/// public class QuestDbManager : IAsyncDisposable { - private const string DockerImage = "questdb/questdb:latest"; - private const string ContainerNamePrefix = "questdb-test-"; - private readonly string _containerName; - private readonly HttpClient _httpClient; + private readonly int _ilpPort; private readonly int _httpPort; - - private readonly int _port; - private string? _containerId; - private string? _volumeName; - - /// - /// Initializes a new instance of the QuestDbManager. - /// - /// ILP port (default: 9009) - /// HTTP port (default: 9000) - public QuestDbManager(int port = 9009, int httpPort = 9000) + private readonly int _pgPort; + private readonly string? _liveHttp; + private readonly string? _liveIlp; + private readonly string _dataDir; + private readonly string[] _extraConf; + private readonly HttpClient _httpClient; + private readonly StringBuilder _serverLog = new(); + private readonly object _logLock = new(); + + private Process? _process; + + /// Initializes a new instance of the QuestDbManager. + /// ILP port (default: 9009). + /// HTTP port (default: 9000); also the WebSocket/QWP port. + /// + /// Extra server.conf lines (e.g. debug fragmentation knobs). Appended verbatim. + /// + public QuestDbManager(int port = 9009, int httpPort = 9000, IEnumerable? extraConf = null) { - _port = port; - _httpPort = httpPort; - _containerName = $"{ContainerNamePrefix}{port}-{httpPort}-{Guid.NewGuid().ToString().Substring(0, 8)}"; - _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5), }; + _ilpPort = port; + _httpPort = httpPort; + _extraConf = extraConf?.ToArray() ?? Array.Empty(); + _pgPort = FindFreeTcpPort(); + _liveHttp = NormalizeEndpoint(Environment.GetEnvironmentVariable("QDB_LIVE_HTTP")); + _liveIlp = NormalizeEndpoint(Environment.GetEnvironmentVariable("QDB_LIVE_ILP")); + _dataDir = Path.Combine( + Path.GetTempPath(), + $"qdb-fixture-{httpPort}-{port}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"); + _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; } + public bool UseLiveServer => !string.IsNullOrEmpty(_liveHttp); + public bool IsRunning { get; private set; } /// - /// Cleanup resources. + /// No-op retained for source compatibility. With the process-launch fixture the + /// per-instance data directory persists across / + /// , so data survives a restart without a Docker volume. /// - public async ValueTask DisposeAsync() + public void SetVolume(string volumeName) { - await StopAsync(); + } - // Clean up Docker volume if one was used - if (!string.IsNullOrEmpty(_volumeName)) + /// Starts the QuestDB server (or connects to the live instance). + public async Task StartAsync() + { + if (IsRunning) { - await RunDockerCommandAsync($"volume rm {_volumeName}"); + return; } - _httpClient?.Dispose(); - } + if (UseLiveServer) + { + Console.WriteLine($"Using live QuestDB at {_liveHttp}"); + await WaitForQuestDbAsync().ConfigureAwait(false); + IsRunning = true; + return; + } - /// - /// Sets a Docker volume to be used for persistent storage. - /// - public void SetVolume(string volumeName) - { - _volumeName = volumeName; - } + var java = ResolveJava(); + var jar = ResolveQuestDbJar(); + EnsureDataDir(); - /// - /// Ensures Docker is available. - /// - public async Task EnsureDockerAvailableAsync() - { - try + var logDir = Path.Combine(_dataDir, "log"); + Directory.CreateDirectory(logDir); + + var startInfo = new ProcessStartInfo { - var (exitCode, output) = await RunDockerCommandAsync("--version"); - if (exitCode != 0) - { - throw new InvalidOperationException("Docker is not available or not working properly"); - } + FileName = java, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = _dataDir, + }; + // Module-path launch, mirroring the Rust fixture's `java -p questdb.jar -m + // io.questdb/io.questdb.ServerMain -d `. + startInfo.ArgumentList.Add("-DQuestDB-Runtime-0"); + startInfo.ArgumentList.Add("-Dnoebug"); + startInfo.ArgumentList.Add("-XX:+UnlockExperimentalVMOptions"); + startInfo.ArgumentList.Add("-XX:+AlwaysPreTouch"); + startInfo.ArgumentList.Add("--add-exports=java.base/jdk.internal.vm=io.questdb"); + startInfo.ArgumentList.Add("-p"); + startInfo.ArgumentList.Add(jar); + startInfo.ArgumentList.Add("-m"); + startInfo.ArgumentList.Add("io.questdb/io.questdb.ServerMain"); + startInfo.ArgumentList.Add("-d"); + startInfo.ArgumentList.Add(_dataDir); + + Console.WriteLine($"Starting QuestDB: {java} (http={_httpPort}, ilp={_ilpPort}, data={_dataDir})"); + + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, e) => AppendLog(e.Data); + process.ErrorDataReceived += (_, e) => AppendLog(e.Data); + + if (!process.Start()) + { + throw new InvalidOperationException("Failed to start the QuestDB java process"); + } - Console.WriteLine($"Docker is available: {output.Trim()}"); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + _process = process; + + try + { + await WaitForQuestDbAsync().ConfigureAwait(false); } catch (Exception ex) { + await StopAsync().ConfigureAwait(false); throw new InvalidOperationException( - "Docker is required to run integration tests. " + - "Please install Docker from https://docs.docker.com/get-docker/", - ex); + $"QuestDB failed to start. Server log tail:\n{LogTail()}", ex); } + + IsRunning = true; + Console.WriteLine("QuestDB is ready"); } - /// - /// Ensures QuestDB Docker image is available (uses local if exists, otherwise pulls latest). - /// - public async Task PullImageAsync() + /// Stops the QuestDB server. The data directory is left intact for a restart. + public async Task StopAsync() { - // Check if image already exists locally - if (await ImageExistsAsync()) + if (UseLiveServer) { - Console.WriteLine($"Docker image already exists locally: {DockerImage}"); + IsRunning = false; return; } - Console.WriteLine($"Pulling Docker image {DockerImage}..."); - var (exitCode, output) = await RunDockerCommandAsync($"pull {DockerImage}"); - if (exitCode != 0) + var process = _process; + if (process is null) { - throw new InvalidOperationException($"Failed to pull Docker image: {output}"); + IsRunning = false; + return; } - Console.WriteLine("Docker image pulled successfully"); - } - - /// - /// Checks if the QuestDB Docker image exists locally. - /// - private async Task ImageExistsAsync() - { - // Use 'docker images' to check if image exists - // Format: docker images --filter "reference=questdb/questdb:latest" --quiet - var (exitCode, output) = await RunDockerCommandAsync($"images --filter \"reference={DockerImage}\" --quiet"); + Console.WriteLine("Stopping QuestDB"); + try + { + if (!process.HasExited) + { + // QuestDB is crash-safe (WAL); a hard kill recovers on next start. + process.Kill(entireProcessTree: true); + } - // If the image exists, output will contain the image ID - // If it doesn't exist, output will be empty - return exitCode == 0 && !string.IsNullOrWhiteSpace(output); + await process.WaitForExitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: failed to stop QuestDB cleanly: {ex.Message}"); + } + finally + { + process.Dispose(); + _process = null; + IsRunning = false; + } } + [System.Runtime.InteropServices.DllImport("libc", SetLastError = true)] + private static extern int kill(int pid, int sig); + /// - /// Starts the QuestDB container. + /// Gracefully stops QuestDB: SIGTERM so the JVM runs shutdown hooks and closes the WAL + /// cleanly (no torn writes, no mid-ack death). Falls back to a hard kill after 30s. + /// Use this for restart scenarios; hard-kills (crash simulation). /// - public async Task StartAsync() + public async Task StopGracefulAsync() { - if (IsRunning) + if (UseLiveServer) { - Console.WriteLine("QuestDB is already running"); + IsRunning = false; return; } - await EnsureDockerAvailableAsync(); - - // Clean up any existing containers using these ports - await CleanupExistingContainersAsync(); - - await PullImageAsync(); - - Console.WriteLine($"Starting QuestDB container: {_containerName}"); - Console.WriteLine($"HTTP port: {_httpPort}, ILP port: {_port}"); - - // Run container with port mappings - // -d: detached mode - // -p: port mappings - // --name: container name - // -v: volume mount (if specified) - var volumeArg = string.IsNullOrEmpty(_volumeName) - ? string.Empty - : $"-v {_volumeName}:/var/lib/questdb "; - - var runArgs = $"run -d " + - $"-p {_httpPort}:9000 " + - $"-p {_port}:9009 " + - $"--name {_containerName} " + - volumeArg + - DockerImage; - - var (exitCode, output) = await RunDockerCommandAsync(runArgs); - if (exitCode != 0) + var process = _process; + if (process is null) { - throw new InvalidOperationException($"Failed to start QuestDB container: {output}"); + IsRunning = false; + return; } - _containerId = output.Trim(); - Console.WriteLine($"QuestDB container started: {_containerId}"); - + Console.WriteLine("Stopping QuestDB (graceful)"); + try + { + if (!process.HasExited) + { + if (OperatingSystem.IsWindows()) + { + process.Kill(entireProcessTree: true); + } + else + { + const int sigterm = 15; + kill(process.Id, sigterm); + } + } - // Wait for QuestDB to be ready - await WaitForQuestDbAsync(); - IsRunning = true; + try + { + await process.WaitForExitAsync(new CancellationTokenSource(TimeSpan.FromSeconds(30)).Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: failed to stop QuestDB gracefully: {ex.Message}"); + } + finally + { + process.Dispose(); + _process = null; + IsRunning = false; + } } - /// - /// Stops the QuestDB container. - /// - public async Task StopAsync() + /// + public async ValueTask DisposeAsync() { - if (!IsRunning || string.IsNullOrEmpty(_containerId)) + await StopAsync().ConfigureAwait(false); + _httpClient.Dispose(); + + try { - return; + if (Directory.Exists(_dataDir)) + { + Directory.Delete(_dataDir, recursive: true); + } } - - Console.WriteLine($"Stopping QuestDB container: {_containerName}"); - - // Stop the container (with 10 second timeout) - var (exitCode, output) = await RunDockerCommandAsync($"stop -t 10 {_containerName}"); - if (exitCode != 0) + catch { - Console.WriteLine($"Warning: Failed to stop container gracefully: {output}"); - // Try force remove - await RunDockerCommandAsync($"rm -f {_containerName}"); + // best-effort cleanup } - - IsRunning = false; - _containerId = null; - Console.WriteLine("QuestDB container stopped"); } - /// - /// Gets the HTTP endpoint for QuestDB. - /// - public string GetHttpEndpoint() - { - return $"localhost:{_httpPort}"; - } + /// Gets the HTTP endpoint (host:port) for QuestDB. + public string GetHttpEndpoint() => _liveHttp ?? $"localhost:{_httpPort}"; - /// - /// Gets the ILP endpoint for QuestDB. - /// - public string GetIlpEndpoint() + /// Gets the ILP (TCP) endpoint for QuestDB. + public string GetIlpEndpoint() => _liveIlp ?? $"localhost:{_ilpPort}"; + + /// Gets the WebSocket (QWP) endpoint for QuestDB. Shares the HTTP port. + public string GetWebSocketEndpoint() => _liveHttp ?? $"localhost:{_httpPort}"; + + private void EnsureDataDir() { - return $"localhost:{_port}"; + var confDir = Path.Combine(_dataDir, "conf"); + Directory.CreateDirectory(confDir); + + var confPath = Path.Combine(confDir, "server.conf"); + if (File.Exists(confPath)) + { + return; + } + + var lines = new List + { + $"http.bind.to=0.0.0.0:{_httpPort}", + $"line.tcp.net.bind.to=0.0.0.0:{_ilpPort}", + $"pg.net.bind.to=0.0.0.0:{_pgPort}", + "http.min.enabled=false", + "line.udp.enabled=false", + "telemetry.enabled=false", + "cairo.commit.lag=100", + "http.request.header.buffer.size=4194304", + }; + lines.AddRange(_extraConf); + lines.Add(string.Empty); + File.WriteAllText(confPath, string.Join('\n', lines)); } - /// - /// Waits for QuestDB to be ready. - /// private async Task WaitForQuestDbAsync() { const int maxAttempts = 120; - var attempts = 0; - - while (attempts < maxAttempts) + for (var attempt = 0; attempt < maxAttempts; attempt++) { + if (!UseLiveServer && _process is { HasExited: true }) + { + throw new InvalidOperationException("QuestDB process exited during startup"); + } + try { - var response = await _httpClient.GetAsync($"http://{GetHttpEndpoint()}/settings"); + var response = await _httpClient.GetAsync($"http://{GetHttpEndpoint()}/settings") + .ConfigureAwait(false); if (response.IsSuccessStatusCode) { - Console.WriteLine("QuestDB is ready"); return; } } catch { - // Ignore and retry + // not up yet — retry } - await Task.Delay(1000); - attempts++; + await Task.Delay(1000).ConfigureAwait(false); } throw new TimeoutException("QuestDB failed to start within 120 seconds"); } - private async Task CleanupExistingContainersAsync() + private static string ResolveJava() { - Console.WriteLine($"Checking for existing containers on ports {_httpPort}/{_port}..."); - - // Get list of all containers (running and stopped) - var (exitCode, output) = await RunDockerCommandAsync("ps -a --format \"{{.Names}}\""); - if (exitCode != 0) + var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME"); + if (!string.IsNullOrWhiteSpace(javaHome)) { - return; // Silently ignore errors listing containers + var exe = OperatingSystem.IsWindows() ? "java.exe" : "java"; + var candidate = Path.Combine(javaHome.Trim(), "bin", exe); + if (File.Exists(candidate)) + { + return candidate; + } } - var containerNames = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + // Fall back to PATH; the process launch surfaces a clear error if java is absent. + return OperatingSystem.IsWindows() ? "java.exe" : "java"; + } - // Stop and remove any QuestDB test containers - foreach (var rawName in containerNames) + private static string ResolveQuestDbJar() + { + var jar = Environment.GetEnvironmentVariable("QUESTDB_JAR"); + if (!string.IsNullOrWhiteSpace(jar)) { - // Trim the name to remove trailing \r or whitespace - var name = rawName.Trim(); - - // Look for containers with matching port pattern: questdb-test-{port}-{httpPort}-* - if (name.Contains(ContainerNamePrefix, StringComparison.Ordinal) && - (name.Contains($"-{_port}-{_httpPort}-", StringComparison.Ordinal) || - name.Contains($"-{_httpPort}-{_port}-", StringComparison.Ordinal))) + jar = jar.Trim(); + if (!File.Exists(jar)) { - Console.WriteLine($"Cleaning up existing container: {name}"); - - // Stop the container - await RunDockerCommandAsync($"stop -t 5 {name}"); + throw new FileNotFoundException($"QUESTDB_JAR points at a missing file: {jar}"); + } + return jar; + } - // Remove the container - await RunDockerCommandAsync($"rm {name}"); + var repo = Environment.GetEnvironmentVariable("QUESTDB_REPO"); + if (!string.IsNullOrWhiteSpace(repo)) + { + var targetDir = Path.Combine(repo.Trim(), "core", "target"); + if (Directory.Exists(targetDir)) + { + var match = Directory.GetFiles(targetDir, "questdb*.jar", SearchOption.AllDirectories) + .Where(p => + { + var n = Path.GetFileName(p); + return n.Contains("SNAPSHOT", StringComparison.Ordinal) + && !n.Contains("-sources", StringComparison.Ordinal) + && !n.Contains("-javadoc", StringComparison.Ordinal) + && !n.Contains("-tests", StringComparison.Ordinal); + }) + .OrderBy(p => p) + .FirstOrDefault(); + if (match is not null) + { + return match; + } } + throw new FileNotFoundException( + $"Could not find questdb*-SNAPSHOT.jar under {targetDir}. Build QuestDB first " + + "(mvn -pl core -am -DskipTests package)."); } + + throw new InvalidOperationException( + "No QuestDB build configured. Set QUESTDB_JAR (a built questdb.jar), QUESTDB_REPO " + + "(a built QuestDB master repo), or QDB_LIVE_HTTP (an already-running instance)."); } - private async Task<(int ExitCode, string Output)> RunDockerCommandAsync(string arguments) + private static int FindFreeTcpPort() { - var startInfo = new ProcessStartInfo - { - FileName = "docker", - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; + // TcpListener only implements IDisposable on net8.0+, so release it explicitly. + var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } - var process = Process.Start(startInfo); - if (process == null) + private static string? NormalizeEndpoint(string? raw) + => string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(); + + private void AppendLog(string? line) + { + if (line is null) { - throw new InvalidOperationException("Failed to start docker command"); + return; } - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - await Task.WhenAll(outputTask, errorTask); - var output = await outputTask; - var error = await errorTask; - await process.WaitForExitAsync(); + lock (_logLock) + { + _serverLog.AppendLine(line); + // Cap the buffer so a long-running fixture doesn't grow unbounded. + if (_serverLog.Length > 256 * 1024) + { + _serverLog.Remove(0, _serverLog.Length - 128 * 1024); + } + } + } - return (process.ExitCode, output + error); + private string LogTail() + { + lock (_logLock) + { + var text = _serverLog.ToString(); + return text.Length <= 8192 ? text : text.Substring(text.Length - 8192); + } } -} \ No newline at end of file +} diff --git a/src/net-questdb-client-tests/QuestDbQueryIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbQueryIntegrationTests.cs new file mode 100644 index 00000000..713262b0 --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbQueryIntegrationTests.cs @@ -0,0 +1,260 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; + +namespace net_questdb_client_tests; + +/// +/// Integration tests against a QuestDB build that ships the /read/v1 egress endpoint +/// (not in any released image yet). Point QUESTDB_IMAGE at any branch image that has it, +/// e.g. QUESTDB_IMAGE=questdb/questdb:<branch> dotnet test --filter QuestDbQueryIntegrationTests. +/// +[TestFixture] +public class QuestDbQueryIntegrationTests +{ + private const int IlpPort = 19209; + private const int HttpPort = 19200; + private QuestDbManager? _questDb; + + [OneTimeSetUp] + public async Task SetUpFixture() + { + _questDb = new QuestDbManager(IlpPort, HttpPort); + await _questDb.StartAsync(); + await DropFixtureTablesAsync(); + await SeedFixtureTableAsync(); + } + + [OneTimeTearDown] + public async Task TearDownFixture() + { + if (_questDb is not null) + { + await DropFixtureTablesAsync(); + await _questDb.StopAsync(); + await _questDb.DisposeAsync(); + } + } + + private async Task DropFixtureTablesAsync() + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var endpoint = _questDb!.GetHttpEndpoint(); + foreach (var table in new[] { "qwp_egress_int_test", "qwp_egress_ddl_smoke" }) + { + var url = $"http://{endpoint}/exec?query={Uri.EscapeDataString($"DROP TABLE IF EXISTS {table}")}"; + using var resp = await http.GetAsync(url); + resp.EnsureSuccessStatusCode(); + } + } + + [Test] + public void SelectConstant_RoundTrips() + { + using var client = QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};"); + var handler = new RecordingHandler(); + client.Execute("SELECT 42 AS answer", handler); + + Assert.That(handler.Ended, Is.True); + Assert.That(handler.LastBatch, Is.Not.Null); + Assert.That(handler.LastBatch!.RowCount, Is.EqualTo(1)); + Assert.That(handler.LastBatch.ColumnCount, Is.EqualTo(1)); + Assert.That(handler.LastBatch.GetColumnWireType(0), Is.EqualTo(QwpTypeCode.Int)); + Assert.That(handler.LastBatch.GetIntValue(0, 0), Is.EqualTo(42)); + } + + [Test] + public void SelectFromSeededTable_ReturnsAllRows() + { + using var client = QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};"); + var handler = new RecordingHandler(); + client.Execute("SELECT id, value FROM qwp_egress_int_test ORDER BY id", handler); + + Assert.That(handler.Ended, Is.True); + Assert.That(handler.TotalRowCount, Is.EqualTo(5)); + var ids = handler.AllLongs(colIndex: 0); + Assert.That(ids, Is.EqualTo(new[] { 1L, 2L, 3L, 4L, 5L })); + } + + [Test] + public void Bind_ParameterFiltersTable() + { + using var client = QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};"); + var handler = new RecordingHandler(); + client.Execute( + "SELECT id FROM qwp_egress_int_test WHERE id = $1", + b => b.SetLong(0, 3L), + handler); + + Assert.That(handler.TotalRowCount, Is.EqualTo(1)); + Assert.That(handler.AllLongs(colIndex: 0), Is.EqualTo(new[] { 3L })); + } + + [Test] + public void DdlStatement_TerminatesViaOnExecDone() + { + using var client = QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};"); + var handler = new RecordingHandler(); + client.Execute("CREATE TABLE qwp_egress_ddl_smoke (a LONG)", handler); + + Assert.That(handler.ExecDoneObserved, Is.True, + "DDL must terminate with EXEC_DONE rather than RESULT_END"); + } + + [Test] + public void ServerInfo_PopulatedOnConnect() + { + using var client = QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};"); + Assert.That(client.ServerInfo, Is.Not.Null, + "Phase-1 server emits SERVER_INFO unconditionally; client must capture it"); + } + + [Test] + public void BadSql_SurfacesQueryErrorViaHandler() + { + using var client = QueryClient.New($"ws::addr={_questDb!.GetWebSocketEndpoint()};"); + var handler = new RecordingHandler(); + client.Execute("SELECT * FROM no_such_table_does_not_exist", handler); + + Assert.That(handler.LastErrorStatus, Is.GreaterThan((byte)0)); + Assert.That(handler.Ended, Is.False); + } + + [Test] + public async Task LargeResultSet_StreamsAcrossMultipleBatchesWithCreditRefills() + { + const string table = "qwp_egress_large_test"; + await SeedLargeFixtureAsync(table, rowCount: 25_000); + + using var client = QueryClient.New(new QueryOptions($"ws::addr={_questDb!.GetWebSocketEndpoint()};") + { + initial_credit = 4096, + }); + var handler = new RecordingHandler(); + client.Execute($"SELECT id FROM {table} ORDER BY id", handler); + + Assert.That(handler.Ended, Is.True); + Assert.That(handler.TotalRowCount, Is.EqualTo(25_000)); + Assert.That(handler.BatchCount, Is.GreaterThan(1)); + + var ids = handler.AllLongs(colIndex: 0); + Assert.That(ids.Length, Is.EqualTo(25_000)); + Assert.That(ids[0], Is.EqualTo(1L)); + Assert.That(ids[^1], Is.EqualTo(25_000L)); + } + + private async Task SeedLargeFixtureAsync(string table, int rowCount) + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var endpoint = _questDb!.GetHttpEndpoint(); + using (var drop = await http.GetAsync( + $"http://{endpoint}/exec?query={Uri.EscapeDataString($"DROP TABLE IF EXISTS {table}")}")) + { + drop.EnsureSuccessStatusCode(); + } + + using var sender = Sender.New($"http::addr={endpoint};auto_flush=off;"); + for (var i = 1; i <= rowCount; i++) + { + sender.Table(table).Column("id", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + + // Wait until the WAL drains the seeded rows. + for (var attempt = 0; attempt < 60; attempt++) + { + using var resp = await http.GetAsync( + $"http://{endpoint}/exec?query={Uri.EscapeDataString($"SELECT count(*) FROM {table}")}"); + if (resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(body); + if (json.RootElement.TryGetProperty("dataset", out var ds) + && ds.GetArrayLength() > 0 && ds[0].GetArrayLength() > 0 + && ds[0][0].GetInt64() >= rowCount) + { + return; + } + } + await Task.Delay(250); + } + Assert.Fail($"Seed of {rowCount} rows into {table} did not complete"); + } + + private async Task SeedFixtureTableAsync() + { + using var sender = Sender.New($"http::addr={_questDb!.GetHttpEndpoint()};auto_flush=off;"); + for (var i = 1; i <= 5; i++) + { + sender.Table("qwp_egress_int_test") + .Column("id", (long)i) + .Column("value", i * 10.0) + .At(DateTime.UtcNow); + } + await sender.SendAsync(); + } + + private sealed class RecordingHandler : QwpColumnBatchHandler + { + private readonly List _columnLongs = new(); + public QwpColumnBatch? LastBatch { get; private set; } + public int TotalRowCount { get; private set; } + public int BatchCount { get; private set; } + public bool Ended { get; private set; } + public bool ExecDoneObserved { get; private set; } + public byte LastErrorStatus { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) + { + LastBatch = batch; + BatchCount++; + TotalRowCount += batch.RowCount; + if (batch.ColumnCount > 0 && batch.GetColumnWireType(0) is QwpTypeCode.Long) + { + var longs = new long[batch.RowCount]; + for (var r = 0; r < batch.RowCount; r++) longs[r] = batch.GetLongValue(0, r); + _columnLongs.Add(longs); + } + } + + public override void OnEnd(long totalRows) => Ended = true; + public override void OnExecDone(QwpOpType opType, long rowsAffected) => ExecDoneObserved = true; + public override void OnError(QwpStatusCode status, string message) => LastErrorStatus = (byte)status; + + public long[] AllLongs(int colIndex) + { + return _columnLongs.SelectMany(x => x).ToArray(); + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbWebSocketIngestFuzzTests.cs b/src/net-questdb-client-tests/QuestDbWebSocketIngestFuzzTests.cs new file mode 100644 index 00000000..dd0b8929 --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbWebSocketIngestFuzzTests.cs @@ -0,0 +1,2229 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Collections.Concurrent; +using System.Globalization; +using System.Numerics; +using System.Text; +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Senders; + +namespace net_questdb_client_tests; + +/// QWP ingress schema-fuzz suite; port of qwp_ws_fuzz.py + TestQwpWsFuzz. +[TestFixture] +public class QuestDbWebSocketIngestFuzzTests +{ + private const int IlpPort = 19209; + private const int HttpPort = 19200; + + private const int BatchSize = 10; + private const int MaxSkippedColumns = 2; + private const int NewColumnRandomizeFactor = 2; + private const int SendSymbolsWithSpaceRandomizeFactor = 2; + private const int UppercaseTableRandomizeFactor = 2; + + // Designated timestamp seed (microseconds since epoch). Matches BASE_TIMESTAMP_US. + private const long BaseTimestampUs = 1_465_839_830_102_300; + + // Non-designated timestamp regions: default near now, pre-epoch ~1922, far future ~2096. + private const long DefaultTsUsBase = 1_700_000_000_000_000; + private const long PreEpochTsUsBase = -1_500_000_000_000_000; + private const long FarFutureTsUsBase = 4_000_000_000_000_000; + private const long TsUsJitter = 86_400_000_000; + + private const long DefaultTsNsBase = 1_700_000_000_000_000_000; + private const long PreEpochTsNsBase = -1_500_000_000_000_000_000; + private const long FarFutureTsNsBase = 4_000_000_000_000_000_000; + private const long TsNsJitter = 86_400_000_000_000; + + private QuestDbManager? _questDb; + + [OneTimeSetUp] + public async Task SetUpFixture() + { + _questDb = new QuestDbManager(IlpPort, HttpPort); + try + { + await _questDb.StartAsync(); + } + catch (Exception ex) when (ex is InvalidOperationException or FileNotFoundException) + { + await _questDb.DisposeAsync(); + _questDb = null; + Assert.Ignore($"QWP ingress fuzz needs a QuestDB master build: {ex.Message}"); + } + } + + [OneTimeTearDown] + public async Task TearDownFixture() + { + if (_questDb is not null) + { + await _questDb.DisposeAsync(); + } + } + + + private static readonly string[][] ColNameBases = + { + new[] { "terület", "TERÜLet", "tERülET", "TERÜLET" }, + new[] { "temperature", "TEMPERATURE", "Temperature", "TempeRaTuRe" }, + new[] { "humidity", "HUMIdity", "HumiditY", "HUmiDIty", "HUMIDITY", "Humidity" }, + new[] { "hőmérséklet", "HŐMÉRSÉKLET", "HŐmérséKLEt", "hőMÉRséKlET" }, + new[] { "notes", "NOTES", "NotEs", "noTeS" }, + new[] { "ветер", "Ветер", "ВЕТЕР", "вЕТЕр", "ВетЕР" }, + new[] { "flag", "FLAG", "Flag", "flAG" }, + new[] { "count", "COUNT", "CounT", "Count" }, + new[] { "price", "PRICE", "pRICE", "Price" }, + new[] { "series_1d", "SERIES_1D", "sERIES_1d", "Series_1d" }, + new[] { "series_2d", "SERIES_2D", "sERIES_2d", "Series_2d" }, + new[] { "series_3d", "SERIES_3D", "sERIES_3d", "Series_3d" }, + new[] { "event_us", "EVENT_US", "event_Us", "Event_US" }, + new[] { "event_ns", "EVENT_NS", "event_Ns", "Event_NS" }, + new[] { "age", "AGE", "Age", "aGE" }, + new[] { "depth", "DEPTH", "DePth", "depTH" }, + new[] { "rank_i", "RANK_I", "rank_I", "Rank_i" }, + new[] { "amount64", "AMOUNT64", "amount_64", "Amount64" }, + new[] { "amount128", "AMOUNT128", "amount_128", "Amount128" }, + new[] { "trace_id", "TRACE_ID", "trace_Id", "TraceID" }, + new[] { "hash256", "HASH256", "hash_256", "Hash256" }, + new[] { "event_ms", "EVENT_MS", "event_Ms", "Event_MS" }, + new[] { "marker_c", "MARKER_C", "marker_C", "Marker_C" }, + new[] { "region", "REGION", "Region", "reGION" }, + new[] { "frame_pos", "FRAME_POS", "frame_Pos", "Frame_PoS" }, + }; + + private enum ColType + { + String, Double, Boolean, Long, Decimal256, + DoubleArray1D, DoubleArray2D, DoubleArray3D, + TimestampMicros, TimestampNanos, + Byte, Short, Int, + Decimal64, Decimal128, + Uuid, Long256, + Date, Char, + Geohash, + Float, + } + + private static readonly ColType[] ColTypes = + { + ColType.String, ColType.Double, ColType.Double, ColType.Double, ColType.String, ColType.Double, + ColType.Boolean, ColType.Long, ColType.Decimal256, + ColType.DoubleArray1D, ColType.DoubleArray2D, ColType.DoubleArray3D, + ColType.TimestampMicros, ColType.TimestampNanos, + ColType.Byte, ColType.Short, ColType.Int, + ColType.Decimal64, ColType.Decimal128, + ColType.Uuid, ColType.Long256, + ColType.Date, ColType.Char, + ColType.Geohash, + ColType.Float, + }; + + private static readonly string[] ColValueBases = + { + "europe", "8", "2", "1", "note", "6", + "", "4", "7", + "", "", "", + "", "", + "3", "5", "9", + "11", "13", + "", "", + "", "", + "", + "", + }; + + private static readonly string[][] SymbolNameBases = + { + new[] { "location", "Location", "LOCATION", "loCATion", "LocATioN" }, + new[] { "city", "ciTY", "CITY" }, + }; + + private static readonly string[] SymbolValueBases = { "us-midwest", "London" }; + + // EXTREME_STRINGS — copied verbatim. Soft hyphen entry contains U+00AD. + private static readonly string[] ExtremeStrings = + { + "", + "x", + new string('X', 256), + "🎉", + "🎉🎉🎉🎉🎉", + "שלום", + "مرحبا", + "naïve café", + "A­B", + }; + + // EXTREME_SYMBOLS — the EXTREME_STRINGS subset without an embedded space. + private static readonly string[] ExtremeSymbols = + ExtremeStrings.Where(s => !s.Contains(' ')).ToArray(); + + // NON_ASCII_CHARS — 10 entries incl. fullwidth space U+3000 and U+3A55. + private static readonly string[] NonAsciiChars = + { "ó", "í", "Á", "ч", "Ъ", "Ж", "ю", " ", "む", "㩕" }; + + private static readonly string[] IntegerColumnTypes = { "BYTE", "SHORT", "INT", "LONG" }; + + private static readonly string[] AlterToleratedPatterns = + { + "type is already", + "designated timestamp", + "cannot change type of column", + "column type is fixed", + "table does not exist", + "no such column", + "column does not exist", + "invalid column", + "unsupported conversion", + }; + + + [Test] + public Task AddColumns() => RunFuzz(r => + { + var load = new LoadParams(15 + r.NextInt(100), 5 + r.NextInt(5), + 2 + r.NextInt(20), 1 + r.NextInt(4), r.NextInt(75)); + var fuzz = new FuzzParams + { + ColumnSkipFactor = 1, NewColumnFactor = 1 + r.NextInt(3), NonAsciiValueFactor = 6, + ExerciseSymbols = true, ColumnConvertProb = 0.1, + }; + return (load, fuzz); + }); + + [Test] + public Task AddColumnsNoSymbols() => RunFuzz(_ => ( + new LoadParams(15, 2, 2, 5, 75), + new FuzzParams + { + NewColumnFactor = 4, NonAsciiValueFactor = 3, DiffCasesInColNames = true, + ExerciseSymbols = false, ColumnConvertProb = 0.15, + })); + + [Test] + public Task AddConvertColumns() => RunFuzz(_ => ( + new LoadParams(15, 2, 2, 5, 75), + new FuzzParams + { + NewColumnFactor = 4, ExerciseSymbols = true, SendSymbolsWithSpace = true, + ColumnConvertProb = 0.2, + })); + + [Test] + public Task AllMixed() => RunFuzz(_ => ( + new LoadParams(50, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 3, ColumnReorderingFactor = 4, ColumnSkipFactor = 5, + NewColumnFactor = 10, NonAsciiValueFactor = 5, DiffCasesInColNames = true, + ExerciseSymbols = true, SendSymbolsWithSpace = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task AllMixedNoSymbols() => RunFuzz(_ => ( + new LoadParams(50, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 3, ColumnReorderingFactor = 4, ColumnSkipFactor = 5, + NewColumnFactor = 10, NonAsciiValueFactor = 5, DiffCasesInColNames = true, + ExerciseSymbols = false, SendSymbolsWithSpace = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task AllMixedSingleTable() => RunFuzz(_ => ( + new LoadParams(50, 5, 5, 1, 50), + new FuzzParams + { + DuplicatesFactor = 3, ColumnReorderingFactor = 4, ColumnSkipFactor = 5, + NewColumnFactor = 10, NonAsciiValueFactor = 5, DiffCasesInColNames = true, + ExerciseSymbols = true, SendSymbolsWithSpace = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task AllMixedSplitPart() => RunFuzz(_ => ( + new LoadParams(50, 5, 5, 1, 50), + new FuzzParams + { + NonAsciiValueFactor = 10, ExerciseSymbols = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task CaseVariationReorderingColumns() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, NewColumnFactor = 2, DiffCasesInColNames = true, + ExerciseSymbols = true, + })); + + [Test] + public Task CaseVariationReorderingColumnsNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, DiffCasesInColNames = true, ExerciseSymbols = false, + })); + + [Test] + public Task CaseVariationReorderingColumnsSendSymbolsWithSpace() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, NewColumnFactor = 3, DiffCasesInColNames = true, + ExerciseSymbols = true, SendSymbolsWithSpace = true, + })); + + [Test] + public Task DuplicatesReorderingColumns() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 4, ColumnReorderingFactor = 4, DiffCasesInColNames = true, + ExerciseSymbols = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task DuplicatesReorderingColumnsNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 4, ColumnReorderingFactor = 4, DiffCasesInColNames = true, + ExerciseSymbols = false, ColumnConvertProb = 0.05, + })); + + [Test] + public Task DuplicatesReorderingColumnsSendSymbolsWithSpace() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 4, ColumnReorderingFactor = 4, DiffCasesInColNames = true, + ExerciseSymbols = true, SendSymbolsWithSpace = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task Load() => RunFuzz(_ => ( + new LoadParams(100, 5, 7, 12, 20), + new FuzzParams())); + + [Test] + public Task LoadLargePayload() => RunFuzz(_ => ( + new LoadParams(500, 5, 5, 5, 10), + new FuzzParams())); + + [Test] + public Task LoadNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 7, 12, 20), + new FuzzParams + { + NonAsciiValueFactor = 5, DiffCasesInColNames = true, ExerciseSymbols = false, + ColumnConvertProb = 0.05, + })); + + [Test] + public Task LoadSendSymbolsWithSpace() => RunFuzz(_ => ( + new LoadParams(100, 5, 4, 8, 20), + new FuzzParams + { + NewColumnFactor = 2, ExerciseSymbols = true, SendSymbolsWithSpace = true, + })); + + [Test] + public Task LoadSmallBuffer() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 20), + new FuzzParams())); + + [Test] + public Task NonAsciiValues() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + NewColumnFactor = 3, NonAsciiValueFactor = 4, ExerciseSymbols = true, + })); + + [Test] + public Task NonAsciiValuesNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + NonAsciiValueFactor = 4, ExerciseSymbols = false, + })); + + [Test] + public Task ReorderingColumns() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, NonAsciiValueFactor = 8, ExerciseSymbols = true, + SendSymbolsWithSpace = true, ColumnConvertProb = 0.05, + })); + + [Test] + public Task ReorderingColumnsNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, DiffCasesInColNames = true, ExerciseSymbols = false, + ColumnConvertProb = 0.05, + })); + + [Test] + public Task ReorderingManyThreads() => RunFuzz(r => + { + var load = new LoadParams(15 + r.NextInt(100), 5 + r.NextInt(5), + 2 + r.NextInt(20), 1 + r.NextInt(4), r.NextInt(75)); + var fuzz = new FuzzParams + { + ColumnReorderingFactor = 3, NewColumnFactor = 1 + r.NextInt(3), + ExerciseSymbols = true, + }; + return (load, fuzz); + }); + + [Test] + public Task ReorderingNonAscii() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, NewColumnFactor = 2, NonAsciiValueFactor = 4, + ExerciseSymbols = true, + })); + + [Test] + public Task ReorderingSkipColumnsWithNonAscii() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, ColumnSkipFactor = 4, NewColumnFactor = 2, + NonAsciiValueFactor = 4, DiffCasesInColNames = true, ExerciseSymbols = true, + })); + + [Test] + public Task ReorderingSkipColumnsWithNonAsciiNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + ColumnReorderingFactor = 4, ColumnSkipFactor = 4, NonAsciiValueFactor = 4, + DiffCasesInColNames = true, ExerciseSymbols = false, + })); + + [Test] + public Task ReorderingSkipDuplicateColumnsWithNonAscii() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 4, ColumnReorderingFactor = 4, ColumnSkipFactor = 4, + NonAsciiValueFactor = 4, DiffCasesInColNames = true, ExerciseSymbols = true, + ColumnConvertProb = 0.05, + })); + + [Test] + public Task ReorderingSkipDuplicateColumnsWithNonAsciiNoSymbols() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 50), + new FuzzParams + { + DuplicatesFactor = 4, ColumnReorderingFactor = 4, ColumnSkipFactor = 4, + NonAsciiValueFactor = 4, DiffCasesInColNames = true, ExerciseSymbols = false, + ColumnConvertProb = 0.05, + })); + + [Test] + public Task LoadWithBounce() => RunFuzz(_ => ( + new LoadParams(100, 5, 5, 5, 400), + new FuzzParams { MaxBounces = 3, MinBounceIntervalMs = 300, MaxBounceIntervalMs = 1500 })); + + [Test] + public Task AllMixedWithBounce() => RunFuzz(_ => ( + new LoadParams(50, 5, 5, 5, 400), + new FuzzParams + { + DuplicatesFactor = 3, ColumnReorderingFactor = 4, ColumnSkipFactor = 5, + NewColumnFactor = 10, NonAsciiValueFactor = 5, DiffCasesInColNames = true, + ExerciseSymbols = true, SendSymbolsWithSpace = true, ColumnConvertProb = 0.05, + MaxBounces = 3, MinBounceIntervalMs = 300, MaxBounceIntervalMs = 1500, + })); + + [Test] + public Task ExtremeStringsTest() => RunFuzz(_ => ( + new LoadParams(80, 5, 4, 3, 30), + new FuzzParams + { + ColumnReorderingFactor = 3, NewColumnFactor = 4, NonAsciiValueFactor = 3, + DiffCasesInColNames = true, ExtremeStringFactor = 2, + })); + + [Test] + public Task ExtremeNumerics() => RunFuzz(_ => ( + new LoadParams(80, 5, 4, 3, 30), + new FuzzParams + { + ColumnReorderingFactor = 3, NewColumnFactor = 4, DiffCasesInColNames = true, + ExtremeNumericFactor = 2, NegativeZeroFactor = 4, + })); + + [Test] + public Task ExtremeTimestamps() => RunFuzz(_ => ( + new LoadParams(80, 5, 4, 3, 30), + new FuzzParams + { + ColumnReorderingFactor = 3, ExtremeTimestampFactor = 2, + })); + + [Test] + public Task ExtremeEverything() => RunFuzz(_ => ( + new LoadParams(80, 5, 4, 3, 30), + new FuzzParams + { + DuplicatesFactor = 4, ColumnReorderingFactor = 3, ColumnSkipFactor = 5, + NewColumnFactor = 4, NonAsciiValueFactor = 3, DiffCasesInColNames = true, + ExerciseSymbols = true, ExtremeStringFactor = 3, ExtremeNumericFactor = 3, + ExtremeTimestampFactor = 3, NegativeZeroFactor = 4, ColumnConvertProb = 0.05, + })); + + [Test] + public Task NBounceSweep() => RunFuzz(_ => ( + new LoadParams(100, 10, 5, 5, 400), + new FuzzParams + { + ColumnReorderingFactor = 4, NewColumnFactor = 5, NonAsciiValueFactor = 4, + DiffCasesInColNames = true, ExerciseSymbols = true, + MaxBounces = 10, MinBounceIntervalMs = 300, MaxBounceIntervalMs = 1200, + })); + + + private async Task RunFuzz(Func builder) + { + var seed = DeriveMasterSeed(); + TestContext.Progress.WriteLine($"{TestContext.CurrentContext.Test.Name} seed=0x{seed:x16}"); + var master = new Rng(seed); + var (load, fuzz) = builder(master); + + var httpEndpoint = _questDb!.GetHttpEndpoint(); + var endpoint = _questDb.GetWebSocketEndpoint(); + + var tables = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < load.NumTables; i++) + { + var name = $"weather{i}"; + tables[name] = new TableData(name); + await ExecAsync(httpEndpoint, $"DROP TABLE IF EXISTS '{name}'"); + } + + long tsCounter = BaseTimestampUs; + long NextTsUs() => Interlocked.Increment(ref tsCounter); + + var failures = new ConcurrentQueue(); + var producersDone = new CancellationTokenSource(); + + if (fuzz.MaxBounces > 0 && _questDb.UseLiveServer) + { + Assert.Ignore("bounce fuzz tests require a managed QuestDB fixture"); + } + + // All producers run in SF mode so they survive a server bounce and replay queued frames. + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-ws-fuzz-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(sfRoot); + var runId = Guid.NewGuid().ToString("N")[..8]; + + try + { + var producers = new List(); + for (var t = 0; t < load.NumThreads; t++) + { + var threadRng = master.Child(); + var sfDir = Path.Combine(sfRoot, $"producer-{t}"); + Directory.CreateDirectory(sfDir); + var senderId = $"fuzz-{runId}-t{t}"; + producers.Add(Task.Run(() => + ProducerLoop(endpoint, sfDir, senderId, load, fuzz, threadRng, tables, NextTsUs, failures))); + } + + Task? alterTask = null; + if (fuzz.ColumnConvertProb > 0) + { + var budget = Math.Max(1, (int)(load.NumLines * load.NumTables * fuzz.ColumnConvertProb)); + var alterRng = master.Child(); + alterTask = Task.Run(() => AlterLoop(httpEndpoint, tables.Keys.ToArray(), budget, alterRng, + producersDone.Token, failures)); + } + + Task? bounceTask = null; + if (fuzz.MaxBounces > 0) + { + var bounceRng = master.Child(); + bounceTask = Task.Run(() => BounceLoop(fuzz, bounceRng, producersDone.Token, failures)); + } + + await Task.WhenAll(producers); + producersDone.Cancel(); + + if (alterTask is not null) + { + await alterTask; + } + + if (bounceTask is not null) + { + await bounceTask; + } + + Assert.That(failures, Is.Empty, $"producer/alter/bounce failures: {string.Join(" | ", failures)}"); + + foreach (var table in tables.Values) + { + var expected = table.RowCount; + if (expected == 0) + { + continue; + } + + await WaitForRowCountAsync(httpEndpoint, table.Name, expected); + } + + foreach (var table in tables.Values) + { + if (table.RowCount == 0) + { + continue; + } + + await CompareTableAsync(httpEndpoint, table, seed); + } + } + finally + { + TryDeleteDirectory(sfRoot); + foreach (var name in tables.Keys) + { + try + { + await ExecAsync(httpEndpoint, $"DROP TABLE IF EXISTS '{name}'"); + } + catch + { + } + } + } + } + + private void ProducerLoop( + string endpoint, string sfDir, string senderId, LoadParams load, FuzzParams fuzz, Rng rng, + Dictionary tables, Func nextTsUs, ConcurrentQueue failures) + { + try + { + using var sender = (IQwpWebSocketSender)Sender.New(BuildProducerConnString(endpoint, sfDir, senderId)); + var points = 0; + for (var iter = 0; iter < load.NumIterations; iter++) + { + for (var line = 0; line < load.NumLines; line++) + { + var tableName = PickTableName(load.NumTables, rng); + var table = tables[tableName]; + var tsUs = nextTsUs(); + var row = GenerateLine(sender, tableName, fuzz, rng, table, tsUs); + table.AddRow(row); + if (++points % BatchSize == 0) + { + sender.SendAsync().GetAwaiter().GetResult(); + } + } + + sender.SendAsync().GetAwaiter().GetResult(); + if (load.WaitMs > 0) + { + Thread.Sleep(load.WaitMs); + } + } + + sender.SendAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + failures.Enqueue($"producer {senderId} failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private LineData GenerateLine( + IQwpWebSocketSender sender, string tableName, FuzzParams fuzz, Rng rng, TableData table, long tsUs) + { + var row = new LineData(tsUs); + sender.Table(tableName); + + if (fuzz.ExerciseSymbols) + { + var symIndexes = SkipColumns( + GenerateOrdering(SymbolNameBases.Length, fuzz.ColumnReorderingFactor, rng), + fuzz.ColumnSkipFactor, rng); + foreach (var symIndex in symIndexes) + { + var symName = AddSymbol(sender, row, symIndex, fuzz, rng); + AddDuplicateSymbol(sender, row, symIndex, symName, fuzz, rng); + AddNewSymbol(sender, row, fuzz, rng); + } + } + + var colIndexes = SkipColumns( + GenerateOrdering(ColNameBases.Length, fuzz.ColumnReorderingFactor, rng), + fuzz.ColumnSkipFactor, rng); + foreach (var colIndex in colIndexes) + { + var colName = AddColumn(sender, row, colIndex, fuzz, rng, table); + AddDuplicateColumn(sender, row, colIndex, colName, fuzz, rng, table); + AddNewColumn(sender, row, fuzz, rng, table); + } + + sender.At(tsUs); + return row; + } + + private string AddColumn(IQwpWebSocketSender sender, LineData row, int colIndex, + FuzzParams fuzz, Rng rng, TableData table) + { + var name = GenerateName(ColNameBases[colIndex], false, fuzz.DiffCasesInColNames, rng); + table.RecordInitialType(name, ColTypes[colIndex]); + var value = AppendColumnValue(sender, ColTypes[colIndex], ColValueBases[colIndex], name, fuzz, rng); + row.Add(name, value); + return name; + } + + private void AddDuplicateColumn(IQwpWebSocketSender sender, LineData row, int colIndex, + string colName, FuzzParams fuzz, Rng rng, TableData table) + { + if (!ShouldFuzz(fuzz.DuplicatesFactor, rng)) + { + return; + } + + table.RecordInitialType(colName, ColTypes[colIndex]); + var value = AppendColumnValue(sender, ColTypes[colIndex], ColValueBases[colIndex], colName, fuzz, rng); + row.Add(colName, value); + } + + private void AddNewColumn(IQwpWebSocketSender sender, LineData row, FuzzParams fuzz, Rng rng, TableData table) + { + if (!ShouldFuzz(fuzz.NewColumnFactor, rng)) + { + return; + } + + var idx = rng.NextInt(ColNameBases.Length); + var name = GenerateName(ColNameBases[idx], true, fuzz.DiffCasesInColNames, rng); + table.RecordInitialType(name, ColTypes[idx]); + var value = AppendColumnValue(sender, ColTypes[idx], ColValueBases[idx], name, fuzz, rng); + row.Add(name, value); + } + + private string AddSymbol(IQwpWebSocketSender sender, LineData row, int symIndex, FuzzParams fuzz, Rng rng) + { + var name = GenerateName(SymbolNameBases[symIndex], false, fuzz.DiffCasesInColNames, rng); + var value = AppendSymbolValue(sender, SymbolValueBases[symIndex], name, fuzz, rng); + row.Add(name, value); + return name; + } + + private void AddDuplicateSymbol(IQwpWebSocketSender sender, LineData row, int symIndex, + string symName, FuzzParams fuzz, Rng rng) + { + if (!ShouldFuzz(fuzz.DuplicatesFactor, rng)) + { + return; + } + + var value = AppendSymbolValue(sender, SymbolValueBases[symIndex], symName, fuzz, rng); + row.Add(symName, value); + } + + private void AddNewSymbol(IQwpWebSocketSender sender, LineData row, FuzzParams fuzz, Rng rng) + { + if (!ShouldFuzz(fuzz.NewColumnFactor, rng)) + { + return; + } + + var idx = rng.NextInt(SymbolNameBases.Length); + var name = GenerateName(SymbolNameBases[idx], true, fuzz.DiffCasesInColNames, rng); + var value = AppendSymbolValue(sender, SymbolValueBases[idx], name, fuzz, rng); + row.Add(name, value); + } + + + private string AppendSymbolValue(IQwpWebSocketSender sender, string valueBase, string colName, + FuzzParams fuzz, Rng rng) + { + string symVal; + if (ShouldFuzz(fuzz.ExtremeStringFactor, rng)) + { + symVal = ExtremeSymbols[rng.NextInt(ExtremeSymbols.Length)]; + } + else + { + var postfix = ShouldFuzz(fuzz.NonAsciiValueFactor, rng) + ? NonAsciiChars[rng.NextInt(NonAsciiChars.Length)] + : ((char)('A' + rng.NextInt(26))).ToString(); + var baseStr = valueBase; + if (fuzz.SendSymbolsWithSpace && rng.NextInt(SendSymbolsWithSpaceRandomizeFactor) == 0 + && baseStr.Length > 1) + { + var spaceIndex = rng.NextInt(baseStr.Length - 1); + baseStr = baseStr[..spaceIndex] + " " + baseStr[spaceIndex..]; + } + + symVal = baseStr + postfix; + } + + sender.Symbol(colName, symVal); + return symVal; + } + + private object AppendColumnValue(IQwpWebSocketSender sender, ColType type, string valueBase, + string colName, FuzzParams fuzz, Rng rng) + { + switch (type) + { + case ColType.Double: + { + var d = rng.NextInt(9); + var fVal = (double)(long.Parse(valueBase, CultureInfo.InvariantCulture) * 10 + d); + if (ShouldFuzz(fuzz.NegativeZeroFactor, rng)) + { + fVal = -0.0; + } + else if (ShouldFuzz(fuzz.ExtremeNumericFactor, rng) && rng.NextBoolean()) + { + fVal = -fVal; + } + + sender.Column(colName, fVal); + return FormatFloat(fVal); + } + case ColType.String: + { + string strVal; + if (ShouldFuzz(fuzz.ExtremeStringFactor, rng)) + { + strVal = ExtremeStrings[rng.NextInt(ExtremeStrings.Length)]; + } + else + { + var postfix = ShouldFuzz(fuzz.NonAsciiValueFactor, rng) + ? NonAsciiChars[rng.NextInt(NonAsciiChars.Length)] + : ((char)('A' + rng.NextInt(26))).ToString(); + strVal = valueBase + postfix; + } + + sender.Column(colName, strVal); + return strVal; + } + case ColType.Boolean: + { + var bVal = rng.NextBoolean(); + sender.Column(colName, bVal); + return bVal; + } + case ColType.Long: + { + var d = rng.NextInt(9); + var iVal = long.Parse(valueBase, CultureInfo.InvariantCulture) * 10 + d; + if (ShouldFuzz(fuzz.ExtremeNumericFactor, rng)) + { + long[] picks = + { + 0, -1, 1, -1L << 30, (1L << 30) - 1, -1L << 60, (1L << 60) - 1, + }; + iVal = picks[rng.NextInt(picks.Length)]; + } + + sender.Column(colName, iVal); + return iVal; + } + case ColType.Byte: + { + var iVal = (int)((long.Parse(valueBase, CultureInfo.InvariantCulture) * 10 + rng.NextInt(9)) % 100); + sender.ColumnByte(colName, (sbyte)iVal); + return iVal; + } + case ColType.Short: + { + var iVal = (int)(long.Parse(valueBase, CultureInfo.InvariantCulture) * 1000 + rng.NextInt(900)); + sender.ColumnShort(colName, (short)iVal); + return iVal; + } + case ColType.Int: + { + var iVal = (int)(long.Parse(valueBase, CultureInfo.InvariantCulture) * 1_000_000 + rng.NextInt(1_000_000)); + sender.Column(colName, iVal); + return iVal; + } + case ColType.Decimal64: + { + var whole = long.Parse(valueBase, CultureInfo.InvariantCulture) * 10 + rng.NextInt(9); + var frac = rng.NextInt(100); + var decStr = $"{whole}.{frac:D2}"; + sender.ColumnDecimal64(colName, decimal.Parse(decStr, CultureInfo.InvariantCulture), 2); + return decStr; + } + case ColType.Decimal128: + { + var whole = long.Parse(valueBase, CultureInfo.InvariantCulture) * 1000 + rng.NextInt(1000); + var frac = rng.NextInt(100); + var decStr = $"{whole}.{frac:D2}"; + sender.ColumnDecimal128(colName, decimal.Parse(decStr, CultureInfo.InvariantCulture), 2); + return decStr; + } + case ColType.Decimal256: + { + var whole = BigInteger.Parse(valueBase, CultureInfo.InvariantCulture) * 10 + rng.NextInt(9); + var frac = rng.NextInt(100); + var negative = false; + if (ShouldFuzz(fuzz.ExtremeNumericFactor, rng)) + { + var shape = rng.NextInt(5); + if (shape == 0) + { + whole = 0; + frac = 0; + } + else if (shape == 1) + { + negative = true; + } + else if (shape == 2) + { + whole = BigInteger.Pow(10, 20 + rng.NextInt(40)) + whole; + } + else if (shape == 3) + { + negative = true; + whole = BigInteger.Pow(10, 20 + rng.NextInt(40)) + whole; + } + } + + var decStr = $"{(negative ? "-" : "")}{whole}.{frac:D2}"; + AppendDecimal256(sender, colName, whole, frac, negative); + return decStr; + } + case ColType.Uuid: + { + var lo = rng.NextLong(); + var hi = rng.NextLong(); + sender.Column(colName, UuidFromLoHi(lo, hi)); + return FormatUuid(lo, hi); + } + case ColType.Long256: + { + var raw = new byte[32]; + for (var i = 0; i < raw.Length; i++) + { + raw[i] = (byte)rng.NextInt(256); + } + + // Non-negative BigInteger from the 32 random bytes (little-endian, unsigned). + var leUnsigned = new byte[33]; + Array.Copy(raw, leUnsigned, 32); + var big = new BigInteger(leUnsigned); + sender.ColumnLong256(colName, big); + return FormatLong256(raw); + } + case ColType.Date: + { + var ms = 1_700_000_000_000L + rng.NextInt(86_400_000); + sender.ColumnDate(colName, ms); + return ms; + } + case ColType.Char: + { + var v = rng.NextInt(0x110000); + if (v < 0x20 || (v >= 0x7F && v <= 0x9F) || (v >= 0xD800 && v <= 0xDFFF) || v >= 0x10000) + { + v = 'A' + rng.NextInt(26); + } + + sender.Column(colName, (char)v); + return char.ConvertFromUtf32(v); + } + case ColType.Geohash: + { + const int precisionBits = 25; + var mask = (1UL << precisionBits) - 1; + var bits = (ulong)rng.NextLong() & mask; + sender.ColumnGeohash(colName, bits, precisionBits); + return $"{bits:x8}/{precisionBits}"; + } + case ColType.Float: + { + var whole = rng.NextInt(1000); + var frac = rng.NextInt(1000); + var fVal = whole + frac / 1000.0f; + sender.ColumnFloat(colName, fVal); + return FormatFloat((float)fVal); + } + case ColType.TimestampMicros: + { + var tsUs = PickTimestampUs(fuzz, rng); + sender.Column(colName, DateTime.SpecifyKind(DateTime.UnixEpoch.AddTicks(tsUs * 10), DateTimeKind.Utc)); + return tsUs * 1000L; + } + case ColType.TimestampNanos: + { + var tsNs = PickTimestampNs(fuzz, rng); + sender.ColumnNanos(colName, tsNs); + return tsNs; + } + case ColType.DoubleArray1D: + { + var length = 2 + rng.NextInt(8); + var arr = new double[length]; + for (var i = 0; i < length; i++) + { + var v = (double)rng.NextInt(100); + if (ShouldFuzz(fuzz.NegativeZeroFactor, rng)) + { + v = -0.0; + } + else if (ShouldFuzz(fuzz.ExtremeNumericFactor, rng) && rng.NextBoolean()) + { + v = -v; + } + + arr[i] = v; + } + + sender.Column(colName, arr); + return arr; + } + case ColType.DoubleArray2D: + { + var rows = 2 + rng.NextInt(2); + var cols = 2 + rng.NextInt(2); + var arr = new double[rows, cols]; + for (var i = 0; i < rows; i++) + { + for (var j = 0; j < cols; j++) + { + arr[i, j] = rng.NextInt(100); + } + } + + sender.Column(colName, arr); + return arr; + } + case ColType.DoubleArray3D: + { + var d1 = 2 + rng.NextInt(2); + var d2 = 2 + rng.NextInt(2); + var d3 = 2 + rng.NextInt(2); + var arr = new double[d1, d2, d3]; + for (var i = 0; i < d1; i++) + { + for (var j = 0; j < d2; j++) + { + for (var k = 0; k < d3; k++) + { + arr[i, j, k] = rng.NextInt(100); + } + } + } + + sender.Column(colName, arr); + return arr; + } + default: + sender.Column(colName, valueBase); + return valueBase; + } + } + + // Always the four-limb path: it pins scale=2 explicitly so every row of the column agrees. + // The decimal overload derives scale from the value and yields scale 0/1 on trailing zeros. + private static void AppendDecimal256(IQwpWebSocketSender sender, string colName, + BigInteger whole, int frac, bool negative) + { + var unscaled = whole * 100 + frac; + if (negative) + { + unscaled = -unscaled; + } + + var twos = unscaled.Sign < 0 ? unscaled + (BigInteger.One << 256) : unscaled; + const ulong limbMask = ulong.MaxValue; + var l0 = (long)(ulong)(twos & limbMask); + var l1 = (long)(ulong)((twos >> 64) & limbMask); + var l2 = (long)(ulong)((twos >> 128) & limbMask); + var l3 = (long)(ulong)((twos >> 192) & limbMask); + sender.ColumnDecimal256(colName, l0, l1, l2, l3, 2); + } + + + private async Task AlterLoop( + string httpEndpoint, string[] tableNames, int budget, Rng rng, + CancellationToken producersDone, ConcurrentQueue failures) + { + var remaining = budget; + while (remaining > 0 && !producersDone.IsCancellationRequested && failures.IsEmpty) + { + var table = tableNames[rng.NextInt(tableNames.Length)]; + if (await TryOneAlterAsync(httpEndpoint, table, rng, failures)) + { + remaining--; + } + + try + { + await Task.Delay(10 + rng.NextInt(100), producersDone); + } + catch (OperationCanceledException) + { + return; + } + } + } + + private async Task TryOneAlterAsync( + string httpEndpoint, string table, Rng rng, ConcurrentQueue failures) + { + List cols; + try + { + cols = await ListColumnsAsync(httpEndpoint, table); + } + catch (Exception e) + { + if (IsTransientNetworkError(e)) + { + return false; + } + + return false; + } + + if (cols.Count == 0) + { + return false; + } + + var start = rng.NextInt(cols.Count); + for (var offset = 0; offset < cols.Count; offset++) + { + var col = cols[(start + offset) % cols.Count]; + if (col.Type == "TIMESTAMP") + { + continue; + } + + var target = ChangeColumnTypeTo(col.Type, rng); + if (target == col.Type) + { + continue; + } + + try + { + await ExecAsync(httpEndpoint, + $"ALTER TABLE \"{table}\" ALTER COLUMN \"{col.Name}\" TYPE {target}"); + return true; + } + catch (Exception e) + { + var classification = ClassifyAlterError(e); + if (classification == AlterErrorClass.Transient) + { + return false; + } + + if (classification == AlterErrorClass.Tolerated) + { + continue; + } + + failures.Enqueue($"fuzz alter: unexpected failure on {table}.{col.Name} -> {target}: " + + $"{e.GetType().Name}: {e.Message}"); + return false; + } + } + + return false; + } + + private static string ChangeColumnTypeTo(string colType, Rng rng) + { + switch (colType) + { + case "STRING": + return rng.NextBoolean() ? "SYMBOL" : "VARCHAR"; + case "SYMBOL": + return rng.NextBoolean() ? "STRING" : "VARCHAR"; + case "VARCHAR": + return rng.NextBoolean() ? "STRING" : "SYMBOL"; + case "FLOAT": + return "DOUBLE"; + case "DOUBLE": + return "FLOAT"; + } + + var rank = Array.IndexOf(IntegerColumnTypes, colType); + if (rank >= 0) + { + var wider = IntegerColumnTypes.Where((_, i) => i > rank).ToArray(); + if (wider.Length == 0) + { + return colType; + } + + return wider[rng.NextInt(wider.Length)]; + } + + return colType; + } + + internal enum AlterErrorClass + { + Transient, + Tolerated, + Fatal, + } + + internal static AlterErrorClass ClassifyAlterError(Exception e) + { + if (IsTransientNetworkError(e)) + { + return AlterErrorClass.Transient; + } + + var message = e.Message.ToLowerInvariant(); + return AlterToleratedPatterns.Any(p => message.Contains(p)) + ? AlterErrorClass.Tolerated + : AlterErrorClass.Fatal; + } + + internal static bool IsTransientNetworkError(Exception ex) + { + for (var e = ex; e is not null; e = e.InnerException!) + { + if (e is HttpRequestException or TaskCanceledException or System.Net.Sockets.SocketException + or TimeoutException or System.IO.IOException) + { + return true; + } + + var message = e.Message.ToLowerInvariant(); + if (message.Contains("connection reset") || message.Contains("connection refused") + || message.Contains("connection aborted") || message.Contains("broken pipe") + || message.Contains("timed out") || message.Contains("timeout")) + { + return true; + } + + if (e.InnerException is null) + { + break; + } + } + + return false; + } + + + private async Task BounceLoop( + FuzzParams fuzz, Rng rng, CancellationToken producersDone, ConcurrentQueue failures) + { + var performed = 0; + while (performed < fuzz.MaxBounces && !producersDone.IsCancellationRequested && failures.IsEmpty) + { + var span = Math.Max(1, fuzz.MaxBounceIntervalMs - fuzz.MinBounceIntervalMs); + var interval = fuzz.MinBounceIntervalMs + rng.NextInt(span); + try + { + await Task.Delay(interval, producersDone); + } + catch (OperationCanceledException) + { + return; + } + + try + { + await _questDb!.StopGracefulAsync(); + await Task.Delay(20 + rng.NextInt(200)); + await _questDb.StartAsync(); + performed++; + } + catch (Exception ex) + { + failures.Enqueue($"bounce #{performed + 1} failed: {ex.GetType().Name}: {ex.Message}"); + try + { + await _questDb!.StartAsync(); + } + catch + { + } + + return; + } + } + } + + // SF mode + long reconnect/close-drain budgets let producers survive a server bounce. + private static string BuildProducerConnString(string endpoint, string sfDir, string senderId) + => $"ws::addr={endpoint};auto_flush=off;sf_dir={sfDir};sender_id={senderId};" + + "reconnect_max_duration_millis=120000;close_flush_timeout_millis=120000;"; + + private static void TryDeleteDirectory(string dir) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + } + } + + + private async Task CompareTableAsync(string httpEndpoint, TableData table, ulong seed) + { + var seedLabel = $"seed=0x{seed:x16}"; + var (columns, rows) = await QuerySortedAsync(httpEndpoint, table.Name); + var expectedRows = table.RowsSortedByTimestamp(); + + Assert.That(rows.Count, Is.EqualTo(expectedRows.Count), + $"[{seedLabel}] table {table.Name}: row count mismatch — " + + $"expected {expectedRows.Count}, server returned {rows.Count}"); + + var hasDesignatedTs = columns.Any(c => + c.Name.Equals("timestamp", StringComparison.OrdinalIgnoreCase) && IsTimestampType(c.Type)); + Assert.That(hasDesignatedTs, Is.True, + $"[{seedLabel}] table {table.Name}: no designated TIMESTAMP column found"); + + for (var rowIndex = 0; rowIndex < expectedRows.Count; rowIndex++) + { + var expectedLine = expectedRows[rowIndex]; + var serverRow = rows[rowIndex]; + for (var colIndex = 0; colIndex < columns.Length; colIndex++) + { + var col = columns[colIndex]; + if (col.Name.Equals("timestamp", StringComparison.OrdinalIgnoreCase) && IsTimestampType(col.Type)) + { + continue; + } + + var hasExpected = expectedLine.TryGet(col.Name, out var expectedRaw); + var actualStr = FormatActualCell(serverRow[colIndex], col.Type); + string expectedStr; + if (!hasExpected) + { + var initialType = table.InitialColType(col.Name) ?? ServerTypeToColType(col.Type); + expectedStr = MissingDefault(initialType); + if (initialType is ColType.Byte or ColType.Short + && ServerTypeToColType(col.Type) != initialType + && actualStr == MissingDefault(ServerTypeToColType(col.Type))) + { + continue; + } + } + else + { + expectedStr = FormatExpectedCell(expectedRaw!, col.Type); + } + + if (expectedStr != actualStr) + { + Assert.Fail($"[{seedLabel}] table {table.Name} row {rowIndex} " + + $"column {col.Name} ({col.Type}): " + + $"expected '{expectedStr}', got '{actualStr}' " + + $"(timestamp_us={expectedLine.TimestampUs})"); + } + } + } + } + + + private static bool IsTimestampType(string serverType) + => serverType == "TIMESTAMP" || serverType.StartsWith("TIMESTAMP_"); + + private static bool IsArrayType(string serverType) + => serverType.Contains('[') || serverType.StartsWith("ARRAY", StringComparison.Ordinal); + + private static bool IsDecimalType(string serverType) => serverType.StartsWith("DECIMAL"); + + private static bool IsGeohashType(string serverType) + => serverType.StartsWith("GEOHASH(") && serverType.EndsWith(")"); + + private static readonly string[] NumericFloatTypes = { "FLOAT", "DOUBLE" }; + private static readonly string[] NumericIntTypes = { "BYTE", "SHORT", "INT", "LONG" }; + + private static string MissingDefault(string serverType) + { + if (NumericFloatTypes.Contains(serverType) || IsArrayType(serverType)) + { + return "null"; + } + + if (serverType is "BYTE" or "SHORT") + { + return "0"; + } + + if (serverType == "BOOLEAN") + { + return "false"; + } + + return ""; + } + + private static string MissingDefault(ColType colType) => colType switch + { + ColType.Float or ColType.Double => "null", + ColType.DoubleArray1D or ColType.DoubleArray2D or ColType.DoubleArray3D => "null", + ColType.Byte or ColType.Short => "0", + ColType.Boolean => "false", + _ => "", + }; + + private static ColType ServerTypeToColType(string serverType) + { + if (IsArrayType(serverType)) + { + return ColType.DoubleArray1D; + } + + return serverType switch + { + "BYTE" => ColType.Byte, + "SHORT" => ColType.Short, + "INT" => ColType.Int, + "LONG" => ColType.Long, + "FLOAT" => ColType.Float, + "DOUBLE" => ColType.Double, + "BOOLEAN" => ColType.Boolean, + "CHAR" => ColType.Char, + "UUID" => ColType.Uuid, + "LONG256" => ColType.Long256, + "DATE" => ColType.Date, + _ => ColType.String, + }; + } + + private static string FormatExpectedCell(object value, string serverType) + { + if (serverType == "FLOAT") + { + var f = value switch + { + float fv => fv, + double dv => (float)dv, + _ => float.Parse(value.ToString() ?? "0", CultureInfo.InvariantCulture), + }; + return FormatFloat32(f); + } + + if (NumericFloatTypes.Contains(serverType)) + { + if (value is double d) + { + return FormatFloat(d); + } + + if (value is float ff) + { + return FormatFloat((double)ff); + } + + return value.ToString() ?? ""; + } + + if (NumericIntTypes.Contains(serverType)) + { + return value switch + { + bool b => b ? "1" : "0", + long l => l.ToString(CultureInfo.InvariantCulture), + int i => i.ToString(CultureInfo.InvariantCulture), + _ => value.ToString() ?? "", + }; + } + + if (serverType == "BOOLEAN") + { + if (value is string s) + { + return s.ToLowerInvariant(); + } + + return value is true ? "true" : "false"; + } + + if (IsTimestampType(serverType)) + { + return value switch + { + long l => l.ToString(CultureInfo.InvariantCulture), + string s => IsoToNanos(s).ToString(CultureInfo.InvariantCulture), + _ => value.ToString() ?? "", + }; + } + + if (serverType == "DATE") + { + return value switch + { + long l => l.ToString(CultureInfo.InvariantCulture), + string s => (IsoToNanos(s) / 1_000_000).ToString(CultureInfo.InvariantCulture), + _ => value.ToString() ?? "", + }; + } + + if (IsGeohashType(serverType)) + { + return FormatGeohashExpected(value, serverType); + } + + if (IsDecimalType(serverType)) + { + return NormalizeDecimal(value); + } + + if (IsArrayType(serverType)) + { + return FormatArray(value); + } + + return value.ToString() ?? ""; + } + + private static string FormatActualCell(JsonElement value, string serverType) + { + if (value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return MissingDefault(serverType); + } + + if (serverType == "FLOAT") + { + var fv = value.ValueKind == JsonValueKind.Number + ? value.GetDouble() + : double.Parse(value.GetString()!, CultureInfo.InvariantCulture); + return FormatFloat32(fv); + } + + if (NumericFloatTypes.Contains(serverType)) + { + var d = value.ValueKind == JsonValueKind.Number + ? value.GetDouble() + : double.Parse(value.GetString()!, CultureInfo.InvariantCulture); + return FormatFloat(d); + } + + if (NumericIntTypes.Contains(serverType)) + { + return value.ValueKind == JsonValueKind.Number + ? value.GetInt64().ToString(CultureInfo.InvariantCulture) + : value.GetString()!; + } + + if (serverType == "BOOLEAN") + { + return value.ValueKind switch + { + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => value.GetString()!.ToLowerInvariant(), + }; + } + + if (IsTimestampType(serverType)) + { + return value.ValueKind == JsonValueKind.Number + ? value.GetInt64().ToString(CultureInfo.InvariantCulture) + : IsoToNanos(value.GetString()!).ToString(CultureInfo.InvariantCulture); + } + + if (serverType == "DATE") + { + return value.ValueKind == JsonValueKind.Number + ? value.GetInt64().ToString(CultureInfo.InvariantCulture) + : (IsoToNanos(value.GetString()!) / 1_000_000).ToString(CultureInfo.InvariantCulture); + } + + if (IsGeohashType(serverType)) + { + return value.ValueKind == JsonValueKind.String ? value.GetString()! : value.ToString(); + } + + if (IsDecimalType(serverType)) + { + return NormalizeDecimal(JsonScalarToString(value)); + } + + if (IsArrayType(serverType)) + { + return FormatJsonArray(value); + } + + return value.ValueKind == JsonValueKind.String ? value.GetString()! : value.ToString(); + } + + private static string JsonScalarToString(JsonElement value) + => value.ValueKind == JsonValueKind.String ? value.GetString()! : value.GetRawText(); + + // Match QuestDB /exec JSON: integer-valued doubles render as e.g. "80.0". + private static string FormatFloat(double f) + { + if (f == 0.0) + { + return "0.0"; + } + + return PythonRepr(f); + } + + // QuestDB renders FLOAT at 32-bit precision; narrow both sides to float before comparing. + private static string FormatFloat32(double value) + { + var f = (float)value; + if (f == 0.0f) + { + return "0.0"; + } + + var s = f.ToString("R", CultureInfo.InvariantCulture); + if (!s.Contains('.') && !s.Contains('e') && !s.Contains('E')) + { + s += ".0"; + } + + return s; + } + + private static string PythonRepr(double f) + { + if (double.IsNaN(f)) + { + return "nan"; + } + + if (double.IsPositiveInfinity(f)) + { + return "inf"; + } + + if (double.IsNegativeInfinity(f)) + { + return "-inf"; + } + + var s = f.ToString("R", CultureInfo.InvariantCulture); + if (s.Contains('E') || s.Contains('e')) + { + // Normalise exponent form to Python's e+NN / e-NN with a mantissa decimal point. + var idx = s.IndexOfAny(new[] { 'E', 'e' }); + var mantissa = s[..idx]; + var exp = s[(idx + 1)..]; + if (!mantissa.Contains('.')) + { + mantissa += ".0"; + } + + var expSign = exp.StartsWith("-") ? "-" : "+"; + var expDigits = exp.TrimStart('+', '-').TrimStart('0'); + if (expDigits.Length == 0) + { + expDigits = "0"; + } + + if (expDigits.Length < 2) + { + expDigits = "0" + expDigits; + } + + return $"{mantissa}e{expSign}{expDigits}"; + } + + if (!s.Contains('.')) + { + s += ".0"; + } + + return s; + } + + private static string FormatUuid(ulong lo, ulong hi) + { + var hex = hi.ToString("x16") + lo.ToString("x16"); + return $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}"; + } + + // Build a Guid whose canonical "D" string equals FormatUuid(lo, hi). + private static Guid UuidFromLoHi(ulong lo, ulong hi) + { + var hex = hi.ToString("x16") + lo.ToString("x16"); + return Guid.ParseExact( + $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}", "D"); + } + + private static string FormatLong256(byte[] raw) + { + var rev = new byte[raw.Length]; + for (var i = 0; i < raw.Length; i++) + { + rev[i] = raw[raw.Length - 1 - i]; + } + + var hex = Convert.ToHexString(rev).ToLowerInvariant(); + while (hex.Length > 2 && hex.StartsWith("00")) + { + hex = hex[2..]; + } + + return "0x" + hex; + } + + private const string GeohashBase32 = "0123456789bcdefghjkmnpqrstuvwxyz"; + + private static (char Suffix, int N) GeohashDimension(string serverType) + { + var inner = serverType[("GEOHASH(".Length)..^1]; + if (inner.Length == 0) + { + return ('\0', -1); + } + + var suffix = inner[^1]; + if (!int.TryParse(inner[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + { + return ('\0', -1); + } + + return (suffix, n); + } + + private static string FormatGeohashExpected(object value, string serverType) + { + if (value is not string s || !s.Contains('/')) + { + return value.ToString() ?? ""; + } + + var hexPart = s[..s.IndexOf('/')]; + if (!ulong.TryParse(hexPart, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var bits)) + { + return s; + } + + var (suffix, n) = GeohashDimension(serverType); + if (suffix == 'c' && n >= 0) + { + var sb = new StringBuilder(); + for (var i = n - 1; i >= 0; i--) + { + sb.Append(GeohashBase32[(int)((bits >> (i * 5)) & 0x1F)]); + } + + return sb.ToString(); + } + + if (suffix == 'b' && n >= 0) + { + var sb = new StringBuilder(); + for (var i = n - 1; i >= 0; i--) + { + sb.Append(((bits >> i) & 1) != 0 ? '1' : '0'); + } + + return sb.ToString(); + } + + return s; + } + + private static string FormatArray(object value) + { + switch (value) + { + case double[] arr1: + return "[" + string.Join(",", arr1.Select(v => FormatFloat(v))) + "]"; + case double[,] arr2: + { + var rows = new List(); + for (var i = 0; i < arr2.GetLength(0); i++) + { + var cells = new List(); + for (var j = 0; j < arr2.GetLength(1); j++) + { + cells.Add(FormatFloat(arr2[i, j])); + } + + rows.Add("[" + string.Join(",", cells) + "]"); + } + + return "[" + string.Join(",", rows) + "]"; + } + case double[,,] arr3: + { + var planes = new List(); + for (var i = 0; i < arr3.GetLength(0); i++) + { + var rows = new List(); + for (var j = 0; j < arr3.GetLength(1); j++) + { + var cells = new List(); + for (var k = 0; k < arr3.GetLength(2); k++) + { + cells.Add(FormatFloat(arr3[i, j, k])); + } + + rows.Add("[" + string.Join(",", cells) + "]"); + } + + planes.Add("[" + string.Join(",", rows) + "]"); + } + + return "[" + string.Join(",", planes) + "]"; + } + default: + return value.ToString() ?? ""; + } + } + + private static string FormatJsonArray(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Array) + { + return "[" + string.Join(",", value.EnumerateArray().Select(FormatJsonArray)) + "]"; + } + + if (value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return "null"; + } + + if (value.ValueKind == JsonValueKind.Number) + { + return FormatFloat(value.GetDouble()); + } + + return value.ToString(); + } + + private static string NormalizeDecimal(object? value) + { + if (value is null) + { + return ""; + } + + return (value.ToString() ?? "").ToLowerInvariant(); + } + + private static readonly DateTime IsoEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static long IsoToNanos(string s) + { + var text = s; + if (text.EndsWith("Z")) + { + text = text[..^1]; + } + + string datePart; + string frac9; + var dot = text.IndexOf('.'); + if (dot >= 0) + { + datePart = text[..dot]; + var frac = text[(dot + 1)..]; + frac9 = (frac + "000000000")[..9]; + } + else + { + datePart = text; + frac9 = "000000000"; + } + + var dt = DateTime.ParseExact(datePart, "yyyy-MM-ddTHH:mm:ss", + CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + var deltaTicks = dt.Ticks - IsoEpoch.Ticks; + // Total whole seconds (can be negative for pre-epoch); ticks are 100ns each. + var secs = deltaTicks / TimeSpan.TicksPerSecond; + return secs * 1_000_000_000L + long.Parse(frac9, CultureInfo.InvariantCulture); + } + + private static long PickTimestampUs(FuzzParams fuzz, Rng rng) + { + long baseUs; + if (ShouldFuzz(fuzz.ExtremeTimestampFactor, rng)) + { + baseUs = rng.NextInt(2) == 0 ? PreEpochTsUsBase : FarFutureTsUsBase; + } + else + { + baseUs = DefaultTsUsBase; + } + + return baseUs + rng.NextLongBounded(TsUsJitter); + } + + private static long PickTimestampNs(FuzzParams fuzz, Rng rng) + { + long baseNs; + if (ShouldFuzz(fuzz.ExtremeTimestampFactor, rng)) + { + baseNs = rng.NextInt(2) == 0 ? PreEpochTsNsBase : FarFutureTsNsBase; + } + else + { + baseNs = DefaultTsNsBase; + } + + return baseNs + rng.NextLongBounded(TsNsJitter); + } + + + private static async Task ExecAsync(string httpEndpoint, string sql) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + using var resp = await client.GetAsync($"http://{httpEndpoint}/exec?query={Uri.EscapeDataString(sql)}"); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(body); + if (json.RootElement.TryGetProperty("error", out var err)) + { + throw new InvalidOperationException(err.GetString()); + } + } + + private static async Task<(ColumnInfo[] Columns, IReadOnlyList Rows)> QuerySortedAsync( + string httpEndpoint, string table) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"SELECT * FROM '{table}' ORDER BY timestamp")}"); + resp.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var root = json.RootElement; + + var columns = root.GetProperty("columns").EnumerateArray() + .Select(c => new ColumnInfo(c.GetProperty("name").GetString()!, c.GetProperty("type").GetString()!)) + .ToArray(); + + var rows = new List(); + if (root.TryGetProperty("dataset", out var dataset) && dataset.ValueKind == JsonValueKind.Array) + { + foreach (var row in dataset.EnumerateArray()) + { + rows.Add(row.EnumerateArray().Select(e => e.Clone()).ToArray()); + } + } + + return (columns, rows); + } + + private static async Task> ListColumnsAsync(string httpEndpoint, string table) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"SHOW COLUMNS FROM '{table}'")}"); + resp.EnsureSuccessStatusCode(); + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var root = json.RootElement; + if (root.TryGetProperty("error", out _)) + { + return new List(); + } + + if (!root.TryGetProperty("columns", out var cols) || !root.TryGetProperty("dataset", out var dataset)) + { + return new List(); + } + + var nameIdx = -1; + var typeIdx = -1; + var ci = 0; + foreach (var c in cols.EnumerateArray()) + { + var n = c.GetProperty("name").GetString(); + if (n == "column") + { + nameIdx = ci; + } + else if (n == "type") + { + typeIdx = ci; + } + + ci++; + } + + if (nameIdx < 0 || typeIdx < 0) + { + return new List(); + } + + var result = new List(); + foreach (var row in dataset.EnumerateArray()) + { + var arr = row.EnumerateArray().ToArray(); + result.Add(new ColumnInfo(arr[nameIdx].GetString()!, arr[typeIdx].GetString()!)); + } + + return result; + } + + private async Task WaitForRowCountAsync(string httpEndpoint, string table, int expected) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + var deadline = DateTime.UtcNow.AddSeconds(120); + long last = -1; + while (DateTime.UtcNow < deadline) + { + try + { + var resp = await client.GetAsync( + $"http://{httpEndpoint}/exec?query={Uri.EscapeDataString($"SELECT count() FROM '{table}'")}"); + if (resp.IsSuccessStatusCode) + { + using var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + if (json.RootElement.TryGetProperty("dataset", out var ds) && ds.GetArrayLength() > 0) + { + last = ds[0][0].GetInt64(); + if (last >= expected) + { + return; + } + } + } + } + catch + { + } + + await Task.Delay(50); + } + + Assert.Fail($"timed out waiting for {expected} rows in {table}; last count={last}"); + } + + + private static bool ShouldFuzz(int factor, Rng rng) => factor > 0 && rng.NextInt(factor) == 0; + + private static string GenerateName(string[] bases, bool randomize, bool diffCases, Rng rng) + { + var caseIndex = diffCases ? rng.NextInt(bases.Length) : 0; + var suffix = randomize ? rng.NextInt(NewColumnRandomizeFactor).ToString() : string.Empty; + return bases[caseIndex] + suffix; + } + + private static List GenerateOrdering(int count, int reorderFactor, Rng rng) + { + var indexes = Enumerable.Range(0, count).ToList(); + if (ShouldFuzz(reorderFactor, rng)) + { + rng.Shuffle(indexes); + } + + return indexes; + } + + private static List SkipColumns(List indexes, int skipFactor, Rng rng) + { + if (!ShouldFuzz(skipFactor, rng)) + { + return indexes; + } + + var result = new List(indexes); + var toSkip = Math.Min(1 + rng.NextInt(MaxSkippedColumns), Math.Max(0, result.Count - 1)); + for (var i = 0; i < toSkip && result.Count > 0; i++) + { + result.RemoveAt(rng.NextInt(result.Count)); + } + + return result; + } + + private static string PickTableName(int numTables, Rng rng) + { + var baseName = rng.NextInt(UppercaseTableRandomizeFactor) == 0 ? "WEATHER" : "weather"; + return baseName + rng.NextInt(numTables); + } + + private static ulong DeriveMasterSeed() + { + var raw = Environment.GetEnvironmentVariable("QWP_WS_FUZZ_SEED"); + if (!string.IsNullOrWhiteSpace(raw)) + { + raw = raw.Trim(); + return raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw[2..], 16) + : ulong.Parse(raw); + } + + return unchecked((ulong)(DateTime.UtcNow.Ticks ^ ((long)Environment.TickCount << 32))); + } + + + private sealed class Rng + { + private readonly Random _impl; + + public Rng(ulong seed) => _impl = new Random(unchecked((int)(seed ^ (seed >> 32)))); + + public int NextInt(int bound) => _impl.Next(bound); + + public bool NextBoolean() => _impl.Next(2) == 1; + + public ulong NextLong() => unchecked((ulong)_impl.NextInt64()); + + public long NextLongBounded(long bound) => _impl.NextInt64(bound); + + public byte[] NextBytes(int n) + { + var b = new byte[n]; + _impl.NextBytes(b); + return b; + } + + public void Shuffle(IList list) + { + for (var i = list.Count - 1; i > 0; i--) + { + var j = _impl.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + } + + public Rng Child() => new(unchecked((ulong)_impl.NextInt64())); + } + + + private sealed class LineData + { + private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase); + + public LineData(long timestampUs) => TimestampUs = timestampUs; + + public long TimestampUs { get; } + + // First write of a column name within a line wins (Java putIfAbsent semantics). + public void Add(string name, object value) => _values.TryAdd(name, value); + + public bool TryGet(string name, out object? value) + { + var found = _values.TryGetValue(name, out var v); + value = v; + return found; + } + } + + private sealed class TableData + { + private readonly object _lock = new(); + private readonly List _rows = new(); + private readonly Dictionary _initialTypes = new(StringComparer.OrdinalIgnoreCase); + + public TableData(string name) => Name = name; + + public string Name { get; } + + public int RowCount + { + get + { + lock (_lock) + { + return _rows.Count; + } + } + } + + public void AddRow(LineData row) + { + lock (_lock) + { + _rows.Add(row); + } + } + + public void RecordInitialType(string column, ColType type) + { + lock (_lock) + { + _initialTypes.TryAdd(column, type); + } + } + + public ColType? InitialColType(string column) + { + lock (_lock) + { + return _initialTypes.TryGetValue(column, out var t) ? t : null; + } + } + + public List RowsSortedByTimestamp() + { + lock (_lock) + { + return _rows.OrderBy(r => r.TimestampUs).ToList(); + } + } + } + + private readonly record struct ColumnInfo(string Name, string Type); + + private sealed class FuzzParams + { + public int DuplicatesFactor { get; init; } = -1; + public int ColumnReorderingFactor { get; init; } = -1; + public int ColumnSkipFactor { get; init; } = -1; + public int NewColumnFactor { get; init; } = -1; + public int NonAsciiValueFactor { get; init; } = -1; + public bool DiffCasesInColNames { get; init; } + public bool ExerciseSymbols { get; init; } = true; + public bool SendSymbolsWithSpace { get; init; } + public double ColumnConvertProb { get; init; } + public int MaxBounces { get; init; } + public int MinBounceIntervalMs { get; init; } = 500; + public int MaxBounceIntervalMs { get; init; } = 3000; + public int ExtremeStringFactor { get; init; } = -1; + public int ExtremeNumericFactor { get; init; } = -1; + public int ExtremeTimestampFactor { get; init; } = -1; + public int NegativeZeroFactor { get; init; } = -1; + } + + private sealed record LoadParams(int NumLines, int NumIterations, int NumThreads, int NumTables, int WaitMs); +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbWebSocketIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbWebSocketIntegrationTests.cs new file mode 100644 index 00000000..aff313d2 --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbWebSocketIntegrationTests.cs @@ -0,0 +1,699 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Globalization; +using System.Text.Json; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp.Query; +using QuestDB.Senders; +using QuestDB.Utils; + +namespace net_questdb_client_tests; + +/// +/// Integration tests against a QuestDB build that ships /write/v4 (currently master, +/// not yet released). Run with QUESTDB_IMAGE=questdb/questdb:master dotnet test --filter +/// QuestDbWebSocketIntegrationTests once the snapshot is available. +/// +[TestFixture] +public class QuestDbWebSocketIntegrationTests +{ + private const int IlpPort = 19109; + private const int HttpPort = 19100; + private QuestDbManager? _questDb; + + [OneTimeSetUp] + public async Task SetUpFixture() + { + _questDb = new QuestDbManager(IlpPort, HttpPort); + await _questDb.StartAsync(); + } + + [OneTimeTearDown] + public async Task TearDownFixture() + { + if (_questDb != null) + { + await _questDb.StopAsync(); + await _questDb.DisposeAsync(); + } + } + + [Test] + public async Task CanSendDataOverWebSocket() + { + var endpoint = _questDb!.GetWebSocketEndpoint(); + using var sender = Sender.New($"ws::addr={endpoint};auto_flush=off;"); + + sender.Table("test_ws_basic") + .Symbol("ticker", "ETH-USD") + .Column("price", 2615.54) + .Column("volume", 1234L) + .At(DateTime.UtcNow); + await sender.SendAsync(); + + await VerifyTableHasDataAsync("test_ws_basic"); + } + + [Test] + public async Task CanSendBatchOverWebSocket() + { + var endpoint = _questDb!.GetWebSocketEndpoint(); + using var sender = Sender.New($"ws::addr={endpoint};auto_flush=off;"); + + for (var i = 0; i < 100; i++) + { + sender.Table("test_ws_batch") + .Symbol("region", i % 2 == 0 ? "us" : "eu") + .Column("seq", (long)i) + .Column("score", i * 1.5) + .At(DateTime.UtcNow); + } + + await sender.SendAsync(); + + await VerifyTableRowCountAsync("test_ws_batch", expected: 100); + } + + [Test] + public async Task CanSendOverStoreAndForward() + { + var endpoint = _questDb!.GetWebSocketEndpoint(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qdb-sf-int-" + Guid.NewGuid().ToString("N")); + try + { + using (var sender = Sender.New( + $"ws::addr={endpoint};auto_flush=off;sf_dir={sfRoot};sender_id=int-test;")) + { + for (var i = 0; i < 10; i++) + { + sender.Table("test_ws_sf") + .Column("seq", (long)i) + .At(DateTime.UtcNow); + } + + await sender.SendAsync(); + ((QuestDB.Senders.IQwpWebSocketSender)sender).Ping(); + } + + await VerifyTableRowCountAsync("test_ws_sf", expected: 10); + } + finally + { + if (Directory.Exists(sfRoot)) + { + try { Directory.Delete(sfRoot, recursive: true); } catch { } + } + } + } + + [Test] + public async Task DurableAck_OnRequestDurableAck_PopulatesSeqTxn() + { + var endpoint = _questDb!.GetWebSocketEndpoint(); + ISender sender; + try + { + sender = Sender.New($"ws::addr={endpoint};auto_flush=off;request_durable_ack=on;"); + } + catch (IngressError ex) when (ex.code == ErrorCode.DurableAckNotSupported) + { + Assert.Inconclusive( + "server did not echo `X-QWP-Durable-Ack: enabled` — primary replication not " + + "configured on this engine; skipping durable-ack assertion"); + return; + } + + using (sender) + { + sender.Table("test_ws_durable").Column("v", 42L).At(DateTime.UtcNow); + await sender.SendAsync(); + + var ws = (IQwpWebSocketSender)sender; + // Ping triggers server-side flush of pending durable-acks; the recv pump processes + // the resulting frame asynchronously, so poll instead of racing the first Ping. + long durableSeqTxn = -1; + for (var i = 0; i < 50 && durableSeqTxn < 0; i++) + { + ws.Ping(); + durableSeqTxn = ws.GetHighestDurableSeqTxn("test_ws_durable"); + if (durableSeqTxn < 0) await Task.Delay(100); + } + Assert.That(durableSeqTxn, Is.GreaterThanOrEqualTo(0L)); + } + } + + [Test] + public async Task RoundTrip_AllSupportedTypes_PreservesValuesViaHttpExec() + { + await DropTableAsync("test_ws_types"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + var ts = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); + var guid = new Guid("11223344-5566-7788-99aa-bbccddeeff00"); + + using (var sender = Sender.New($"ws::addr={endpoint};auto_flush=off;")) + { + sender.Table("test_ws_types") + .Symbol("sym", "alpha") + .Column("s", "hello 世界") + .Column("i", 42) + .Column("l", 9_876_543_210L) + .Column("d", 3.14159) + .Column("b", true) + .Column("c", 'Q') + .Column("g", guid) + .ColumnDecimal64("dec", 12345.67m, 2) + .ColumnNanos("tsn", 1_700_000_000_123_456_789L) + .At(ts); + await sender.SendAsync(); + } + + await VerifyTableRowCountAsync("test_ws_types", expected: 1); + + var row = await QueryFirstRowAsync( + "select sym, s, i, l, d, b, c, g, dec, tsn from test_ws_types"); + Assert.That(row[0].GetString(), Is.EqualTo("alpha")); + Assert.That(row[1].GetString(), Is.EqualTo("hello 世界")); + Assert.That(row[2].GetInt32(), Is.EqualTo(42)); + Assert.That(row[3].GetInt64(), Is.EqualTo(9_876_543_210L)); + Assert.That(row[4].GetDouble(), Is.EqualTo(3.14159).Within(1e-9)); + Assert.That(row[5].GetBoolean(), Is.True); + Assert.That(row[6].GetString(), Is.EqualTo("Q")); + Assert.That(row[7].GetString(), Is.EqualTo(guid.ToString())); + Assert.That(decimal.Parse(row[8].GetString()!, CultureInfo.InvariantCulture), + Is.EqualTo(12345.67m)); + Assert.That(row[9].GetString(), Is.EqualTo("2023-11-14T22:13:20.123456789Z")); + } + + [Test] + public async Task RoundTrip_AllSupportedTypes_PreservesValuesViaEgress() + { + await DropTableAsync("test_ws_egress_rt"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + var ts = new DateTime(2026, 5, 11, 13, 0, 0, DateTimeKind.Utc); + + using (var sender = Sender.New($"ws::addr={endpoint};auto_flush=off;")) + { + sender.Table("test_ws_egress_rt") + .Symbol("sym", "beta") + .Column("s", "round-trip") + .Column("i", -7) + .Column("l", -123_456_789L) + .Column("d", -2.5) + .Column("b", false) + .At(ts); + await sender.SendAsync(); + } + + await VerifyTableRowCountAsync("test_ws_egress_rt", expected: 1); + + using var client = QueryClient.New($"ws::addr={endpoint};"); + var handler = new SingleRowRecordingHandler(); + client.Execute("select sym, s, i, l, d, b from test_ws_egress_rt", handler); + + Assert.That(handler.Ended, Is.True); + var batch = handler.Batch ?? throw new InvalidOperationException("batch missing"); + Assert.That(batch.RowCount, Is.EqualTo(1)); + Assert.That(batch.GetSymbol(0, 0), Is.EqualTo("beta")); + Assert.That(batch.GetString(1, 0), Is.EqualTo("round-trip")); + Assert.That(batch.GetIntValue(2, 0), Is.EqualTo(-7)); + Assert.That(batch.GetLongValue(3, 0), Is.EqualTo(-123_456_789L)); + Assert.That(batch.GetDoubleValue(4, 0), Is.EqualTo(-2.5).Within(1e-12)); + Assert.That(batch.GetBoolValue(5, 0), Is.False); + } + + [Test] + public async Task Reconnect_DuringDbRestart_SfReplaysAllRows() + { + await DropTableAsync("test_ws_restart"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qdb-int-restart-" + Guid.NewGuid().ToString("N")); + try + { + using (var sender = Sender.New( + $"ws::addr={endpoint};auto_flush=off;sf_dir={sfRoot};" + + "sender_id=restart-test;reconnect_max_duration_millis=60000;" + + "reconnect_initial_backoff_millis=50;reconnect_max_backoff_millis=500;")) + { + await _questDb.StopAsync(); + await Task.Delay(500); + + for (var i = 0; i < 30; i++) + { + sender.Table("test_ws_restart").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + + await _questDb.StartAsync(); + + var qwp = (IQwpWebSocketSender)sender; + for (var i = 0; i < 200; i++) + { + try { await qwp.PingAsync(); break; } + catch { await Task.Delay(100); } + } + } + + await VerifyTableRowCountAsync("test_ws_restart", expected: 30, maxAttempts: 150); + } + finally + { + if (Directory.Exists(sfRoot)) + { + try { Directory.Delete(sfRoot, recursive: true); } catch { } + } + } + } + + [Test] + public async Task Reconnect_WsCloseMidSession_SfReplaysQueuedRows() + { + await DropTableAsync("test_ws_close_sf"); + var realEndpoint = _questDb!.GetWebSocketEndpoint(); + using var proxy = new TcpProxy(realEndpoint); + await proxy.StartAsync(); + + var sfRoot = Path.Combine(Path.GetTempPath(), "qdb-int-wsclose-sf-" + Guid.NewGuid().ToString("N")); + try + { + using (var sender = Sender.New( + $"ws::addr={proxy.LocalEndpoint};auto_flush=off;sf_dir={sfRoot};" + + "sender_id=wsclose-sf-test;reconnect_max_duration_millis=60000;" + + "reconnect_initial_backoff_millis=50;reconnect_max_backoff_millis=500;")) + { + var qwp = (IQwpWebSocketSender)sender; + + for (var i = 0; i < 10; i++) + { + sender.Table("test_ws_close_sf").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + await qwp.PingAsync(); + + proxy.KillAllConnections(); + + for (var i = 10; i < 30; i++) + { + sender.Table("test_ws_close_sf").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + + for (var i = 0; i < 200; i++) + { + try { await qwp.PingAsync(); break; } + catch { await Task.Delay(100); } + } + } + + await VerifyTableRowCountAsync("test_ws_close_sf", expected: 30, maxAttempts: 150); + } + finally + { + if (Directory.Exists(sfRoot)) + { + try { Directory.Delete(sfRoot, recursive: true); } catch { } + } + } + } + + [Test] + public async Task Reconnect_WsCloseMidSession_NoSfDir_ReplaysFromRamRing() + { + await DropTableAsync("test_ws_close_ram"); + var realEndpoint = _questDb!.GetWebSocketEndpoint(); + using var proxy = new TcpProxy(realEndpoint); + await proxy.StartAsync(); + + using (var sender = Sender.New( + $"ws::addr={proxy.LocalEndpoint};auto_flush=off;" + + "reconnect_max_duration_millis=60000;" + + "reconnect_initial_backoff_millis=50;reconnect_max_backoff_millis=500;")) + { + var qwp = (IQwpWebSocketSender)sender; + + for (var i = 0; i < 10; i++) + { + sender.Table("test_ws_close_ram").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + await qwp.PingAsync(); + + proxy.KillAllConnections(); + + for (var i = 10; i < 30; i++) + { + sender.Table("test_ws_close_ram").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + + for (var i = 0; i < 200; i++) + { + try { await qwp.PingAsync(); break; } + catch { await Task.Delay(100); } + } + } + + await VerifyTableRowCountAsync("test_ws_close_ram", expected: 30, maxAttempts: 150); + } + + [Test] + public async Task SchemaCacheReuse_TwoFlushesSameTable_BothLand() + { + await DropTableAsync("test_ws_schema_reuse"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + using (var sender = Sender.New($"ws::addr={endpoint};auto_flush=off;")) + { + for (var i = 0; i < 3; i++) + { + sender.Table("test_ws_schema_reuse").Symbol("s", "x").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + + for (var i = 3; i < 6; i++) + { + sender.Table("test_ws_schema_reuse").Symbol("s", "y").Column("v", (long)i).At(DateTime.UtcNow); + } + await sender.SendAsync(); + } + + await VerifyTableRowCountAsync("test_ws_schema_reuse", expected: 6); + } + + [Test] + public async Task SymbolDeltaDict_SecondFlushAddsNewSymbol_BothPersist() + { + await DropTableAsync("test_ws_sym_delta"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + using (var sender = Sender.New($"ws::addr={endpoint};auto_flush=off;")) + { + sender.Table("test_ws_sym_delta").Symbol("sym", "first").Column("v", 1L).At(DateTime.UtcNow); + sender.Table("test_ws_sym_delta").Symbol("sym", "second").Column("v", 2L).At(DateTime.UtcNow); + await sender.SendAsync(); + + sender.Table("test_ws_sym_delta").Symbol("sym", "third").Column("v", 3L).At(DateTime.UtcNow); + await sender.SendAsync(); + } + + await VerifyTableRowCountAsync("test_ws_sym_delta", expected: 3); + + var distinct = await QueryAllRowsAsync( + "select distinct sym from test_ws_sym_delta order by sym"); + var symbols = distinct.Select(r => r[0].GetString()).OrderBy(s => s).ToArray(); + Assert.That(symbols, Is.EqualTo(new[] { "first", "second", "third" })); + } + + [Test] + public async Task ServerErrorFrame_IncompatibleSchema_SurfacesViaErrorHandler() + { + await DropTableAsync("test_ws_err_frame"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + + using (var setup = Sender.New($"ws::addr={endpoint};auto_flush=off;")) + { + setup.Table("test_ws_err_frame").Column("v", 1L).At(DateTime.UtcNow); + await setup.SendAsync(); + } + await VerifyTableRowCountAsync("test_ws_err_frame", expected: 1); + + SenderError? observed = null; + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Fresh sender's per-column buffer doesn't carry the Long type lock from the first + // sender, so the conflicting Varchar bytes actually reach the wire and the server + // returns SCHEMA_MISMATCH. + using (var sender = Sender.New(new SenderOptions( + $"ws::addr={endpoint};auto_flush=off;on_server_error=halt;") + { + error_handler = err => { observed = err; gate.TrySetResult(true); }, + })) + { + sender.Table("test_ws_err_frame").Column("v", "not a long").At(DateTime.UtcNow); + try { await sender.SendAsync(); } catch (IngressError) { } + } + + var fired = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(5))) == gate.Task; + Assert.That(fired, Is.True, "error handler never fired"); + Assert.That(observed, Is.Not.Null); + Assert.That(observed!.Category, Is.AnyOf( + SenderErrorCategory.SchemaMismatch, + SenderErrorCategory.WriteError)); + } + + [Test] + public async Task Transaction_DeferredRowsInvisibleUntilCommit() + { + await DropTableAsync("test_ws_txn_visibility"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + const int rows = 20; + + using var sender = Sender.New( + $"ws::addr={endpoint};transaction=on;auto_flush_rows={rows};auto_flush_interval=off;auto_flush_bytes=off;"); + var qwp = (IQwpWebSocketSender)sender; + + var ts = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + for (var i = 0; i < rows; i++) + { + sender.Table("test_ws_txn_visibility").Column("v", (long)i).At(ts.AddSeconds(i)); + } + + // The final At() hit auto_flush_rows, shipping a single deferred frame (FSN 0). Wait for the + // server to ack it, so the rows are provably appended-but-uncommitted before we assert + // invisibility (otherwise count==0 could just mean "not yet sent"). + Assert.That(await qwp.AwaitAckedFsnAsync(0, TimeSpan.FromSeconds(15)), Is.True, + "deferred frame was not acknowledged by the server"); + + // Give any (incorrect) immediate commit + WAL apply time to surface before asserting. + await Task.Delay(750); + if (await CountRowsAsync("test_ws_txn_visibility") != 0) + { + Assert.Inconclusive( + "server committed deferred rows immediately — this QuestDB build does not honour " + + "FLAG_DEFER_COMMIT; skipping the transactional-visibility assertion"); + return; + } + + await sender.CommitAsync(); + await VerifyTableRowCountAsync("test_ws_txn_visibility", expected: rows); + } + + [Test] + public async Task Transaction_LargeBatch_StagedThenCommittedAtomically() + { + await DropTableAsync("test_ws_txn_large"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + const int rows = 20_000; + const int autoFlushRows = 1_000; + + using var sender = Sender.New( + $"ws::addr={endpoint};transaction=on;auto_flush_rows={autoFlushRows};" + + "auto_flush_interval=off;auto_flush_bytes=off;"); + var qwp = (IQwpWebSocketSender)sender; + + var ts = new DateTime(2026, 6, 2, 0, 0, 0, DateTimeKind.Utc); + for (var i = 0; i < rows; i++) + { + sender.Table("test_ws_txn_large").Symbol("g", "g" + (i % 8)).Column("v", (long)i).At(ts.AddMilliseconds(i)); + } + + // rows/autoFlushRows deferred frames shipped; wait for the last (FSN = count-1) to ack. + Assert.That(await qwp.AwaitAckedFsnAsync(rows / autoFlushRows - 1, TimeSpan.FromSeconds(30)), Is.True); + await Task.Delay(750); + if (await CountRowsAsync("test_ws_txn_large") != 0) + { + Assert.Inconclusive("server does not honour FLAG_DEFER_COMMIT; skipping"); + return; + } + + await sender.CommitAsync(); + await VerifyTableRowCountAsync("test_ws_txn_large", expected: rows, maxAttempts: 150); + } + + [Test] + public async Task Transaction_MultiTable_CommitsAllAtomically() + { + await DropTableAsync("test_ws_txn_t1"); + await DropTableAsync("test_ws_txn_t2"); + var endpoint = _questDb!.GetWebSocketEndpoint(); + const int perTable = 25; + + using var sender = Sender.New( + $"ws::addr={endpoint};transaction=on;auto_flush_rows=10;auto_flush_interval=off;auto_flush_bytes=off;"); + var qwp = (IQwpWebSocketSender)sender; + + var ts = new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc); + for (var i = 0; i < perTable; i++) + { + sender.Table("test_ws_txn_t1").Column("v", (long)i).At(ts.AddSeconds(i)); + sender.Table("test_ws_txn_t2").Column("v", (long)(i * 10)).At(ts.AddSeconds(i)); + } + + await sender.CommitAsync(); + + await VerifyTableRowCountAsync("test_ws_txn_t1", expected: perTable); + await VerifyTableRowCountAsync("test_ws_txn_t2", expected: perTable); + } + + private async Task CountRowsAsync(string tableName) + { + var endpoint = _questDb!.GetHttpEndpoint(); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + try + { + var response = await client.GetAsync( + $"http://{endpoint}/exec?query={Uri.EscapeDataString($"select count(*) from {tableName}")}"); + if (!response.IsSuccessStatusCode) return 0; // table not created yet → nothing visible + var content = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(content); + if (json.RootElement.TryGetProperty("dataset", out var dataset) + && dataset.ValueKind == JsonValueKind.Array && dataset.GetArrayLength() > 0) + { + var row = dataset[0]; + if (row.ValueKind == JsonValueKind.Array && row.GetArrayLength() > 0) + { + return row[0].GetInt64(); + } + } + return 0; + } + catch + { + return 0; + } + } + + private async Task DropTableAsync(string tableName) + { + var endpoint = _questDb!.GetHttpEndpoint(); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var url = $"http://{endpoint}/exec?query={Uri.EscapeDataString($"DROP TABLE IF EXISTS {tableName}")}"; + using var resp = await client.GetAsync(url); + resp.EnsureSuccessStatusCode(); + } + + private async Task QueryFirstRowAsync(string sql) + { + var rows = await QueryAllRowsAsync(sql); + if (rows.Count == 0) Assert.Fail($"query returned no rows: {sql}"); + return rows[0]; + } + + private async Task> QueryAllRowsAsync(string sql) + { + var endpoint = _questDb!.GetHttpEndpoint(); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var response = await client.GetAsync( + $"http://{endpoint}/exec?query={Uri.EscapeDataString(sql)}"); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(content); + if (!json.RootElement.TryGetProperty("dataset", out var dataset) + || dataset.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + var result = new List(dataset.GetArrayLength()); + foreach (var row in dataset.EnumerateArray()) + { + var cols = new JsonElement[row.GetArrayLength()]; + var i = 0; + foreach (var cell in row.EnumerateArray()) cols[i++] = cell.Clone(); + result.Add(cols); + } + return result; + } + + private sealed class SingleRowRecordingHandler : QwpColumnBatchHandler + { + public QwpColumnBatch? Batch { get; private set; } + public bool Ended { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) => Batch = batch; + public override void OnEnd(long totalRows) => Ended = true; + public override void OnError(QwpStatusCode status, string message) => + Assert.Fail($"unexpected egress error: status={status}, msg={message}"); + } + + private async Task VerifyTableHasDataAsync(string tableName) + { + var value = await GetTableRowCountAsync(tableName, minimum: 1); + Assert.That(value, Is.GreaterThan(0)); + } + + private async Task VerifyTableRowCountAsync(string tableName, long expected, int maxAttempts = 30) + { + var value = await GetTableRowCountAsync(tableName, minimum: expected, maxAttempts: maxAttempts); + Assert.That(value, Is.GreaterThanOrEqualTo(expected)); + } + + private async Task GetTableRowCountAsync(string tableName, long minimum, int maxAttempts = 30) + { + var endpoint = _questDb!.GetHttpEndpoint(); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + + var attempts = 0; + + while (attempts < maxAttempts) + { + try + { + var response = await client.GetAsync( + $"http://{endpoint}/exec?query=select count(*) from {tableName}"); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(content); + if (json.RootElement.TryGetProperty("dataset", out var dataset) + && dataset.ValueKind == JsonValueKind.Array + && dataset.GetArrayLength() > 0) + { + var row = dataset[0]; + if (row.ValueKind == JsonValueKind.Array && row.GetArrayLength() > 0) + { + var rowCount = row[0].GetInt64(); + if (rowCount >= minimum) + { + return rowCount; + } + } + } + } + } + catch + { + } + + await Task.Delay(200); + attempts++; + } + + Assert.Fail($"Table {tableName} did not reach {minimum} rows after {maxAttempts} attempts"); + return 0; + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Query/QueryOptionsTests.cs b/src/net-questdb-client-tests/Qwp/Query/QueryOptionsTests.cs new file mode 100644 index 00000000..0ccfe76e --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QueryOptionsTests.cs @@ -0,0 +1,604 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QueryOptionsTests +{ + [Test] + public void Defaults_MatchesSpec() + { + var o = new QueryOptions(); + Assert.That(o.protocol, Is.EqualTo(ProtocolType.ws)); + Assert.That(o.addr, Is.EqualTo("localhost:9000")); + Assert.That(o.path, Is.EqualTo(QwpConstants.ReadPath)); + Assert.That(o.tls_verify, Is.EqualTo(TlsVerifyType.on)); + Assert.That(o.compression, Is.EqualTo(CompressionType.raw)); + Assert.That(o.compression_level, Is.EqualTo(1)); + Assert.That(o.target, Is.EqualTo(TargetType.any)); + Assert.That(o.failover, Is.True); + Assert.That(o.failover_max_attempts, Is.EqualTo(8)); + Assert.That(o.failover_backoff_initial_ms.TotalMilliseconds, Is.EqualTo(50)); + Assert.That(o.failover_backoff_max_ms.TotalMilliseconds, Is.EqualTo(1000)); + Assert.That(o.max_batch_rows, Is.EqualTo(0)); + Assert.That(o.initial_credit, Is.EqualTo(0)); + } + + [Test] + public void InitialCredit_SetViaObjectInitializer() + { + var o = new QueryOptions { addr = "h:9000", initial_credit = 1024 }; + Assert.That(o.initial_credit, Is.EqualTo(1024)); + Assert.DoesNotThrow(() => o.EnsureValid()); + } + + [Test] + public void Parse_InitialCredit_AcceptedAsConnectStringKey() + { + var o = new QueryOptions("ws::addr=h:9000;initial_credit=1024;"); + Assert.That(o.initial_credit, Is.EqualTo(1024)); + } + + [Test] + public void InitialCredit_Negative_Rejected() + { + var o = new QueryOptions { addr = "h:9000", initial_credit = -1 }; + var ex = Assert.Throws(() => o.EnsureValid()); + StringAssert.Contains("initial_credit", ex!.Message); + } + + [Test] + public void Parse_MinimalWs_AssignsAddr() + { + var o = new QueryOptions("ws::addr=db.internal:9000;"); + Assert.That(o.protocol, Is.EqualTo(ProtocolType.ws)); + Assert.That(o.addr, Is.EqualTo("db.internal:9000")); + Assert.That(o.AddressCount, Is.EqualTo(1)); + } + + [Test] + public void Parse_Zone_SetsZone() + { + var o = new QueryOptions("ws::addr=h:9000;zone=eu-west-1a;"); + Assert.That(o.zone, Is.EqualTo("eu-west-1a")); + } + + [Test] + public void Parse_Zone_DefaultIsNull() + { + var o = new QueryOptions("ws::addr=h:9000;"); + Assert.That(o.zone, Is.Null); + } + + [Test] + public void Parse_Zone_RejectsControlChars() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;zone=eu\nwest;")); + } + + [Test] + public void Parse_Wss_SwitchesProtocol() + { + var o = new QueryOptions("wss::addr=secure.host:443;"); + Assert.That(o.protocol, Is.EqualTo(ProtocolType.wss)); + } + + [Test] + public void Parse_AllEgressKnobs_RoundTrip() + { + var o = new QueryOptions( + "wss::addr=a:9000;path=/read/v1;client_id=dashboard/2;" + + "tls_verify=on;tls_roots=/etc/ca.pem;tls_roots_password=secret;" + + "compression=zstd;compression_level=5;" + + "target=primary;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=100;failover_backoff_max_ms=2000;" + + "max_batch_rows=5000;token=abc;"); + + Assert.That(o.protocol, Is.EqualTo(ProtocolType.wss)); + Assert.That(o.addr, Is.EqualTo("a:9000")); + Assert.That(o.path, Is.EqualTo("/read/v1")); + Assert.That(o.client_id, Is.EqualTo("dashboard/2")); + Assert.That(o.tls_verify, Is.EqualTo(TlsVerifyType.on)); + Assert.That(o.tls_roots, Is.EqualTo("/etc/ca.pem")); + Assert.That(o.tls_roots_password, Is.EqualTo("secret")); + Assert.That(o.compression, Is.EqualTo(CompressionType.zstd)); + Assert.That(o.compression_level, Is.EqualTo(5)); + Assert.That(o.target, Is.EqualTo(TargetType.primary)); + Assert.That(o.failover, Is.True); + Assert.That(o.failover_max_attempts, Is.EqualTo(4)); + Assert.That(o.failover_backoff_initial_ms.TotalMilliseconds, Is.EqualTo(100)); + Assert.That(o.failover_backoff_max_ms.TotalMilliseconds, Is.EqualTo(2000)); + Assert.That(o.max_batch_rows, Is.EqualTo(5000)); + Assert.That(o.token, Is.EqualTo("abc")); + } + + [TestCase("http::addr=h:9000;")] + [TestCase("tcp::addr=h:9000;")] + [TestCase("https::addr=h:9000;")] + public void Parse_NonWebSocketScheme_Rejected(string conn) + { + Assert.Throws(() => new QueryOptions(conn)); + } + + [Test] + public void Parse_NoSchemeSeparator_Rejected() + { + Assert.Throws(() => new QueryOptions("addr=h:9000")); + } + + [Test] + public void Parse_UnknownKey_Rejected() + { + var ex = Assert.Throws(() => + new QueryOptions("ws::addr=h:9000;not_a_real_key=42;")); + Assert.That(ex!.Message, Does.Contain("not_a_real_key")); + } + + [Test] + public void Parse_MalformedEntry_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;orphan;")); + } + + [Test] + public void Parse_EmptyKeyOrValue_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;=v;")); + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;k=;")); + } + + [Test] + public void Parse_MultipleAddr_AccumulatesAndPicksFirst() + { + var o = new QueryOptions("ws::addr=a:9000;addr=b:9000;addr=c:9000;"); + Assert.That(o.addr, Is.EqualTo("a:9000")); + Assert.That(o.AddressCount, Is.EqualTo(3)); + Assert.That(o.addresses, Is.EqualTo(new[] { "a:9000", "b:9000", "c:9000" })); + } + + [Test] + public void Parse_CommaSeparatedAddr_SplitsIntoMultipleEndpoints() + { + var o = new QueryOptions("ws::addr=a:9000,b:9000,c:9000;"); + Assert.That(o.AddressCount, Is.EqualTo(3)); + Assert.That(o.addresses, Is.EqualTo(new[] { "a:9000", "b:9000", "c:9000" })); + } + + [Test] + public void Parse_MixedCommaAndRepeatedAddr_AccumulatesAll() + { + var o = new QueryOptions("ws::addr=a:9000,b:9000;addr=c:9000;"); + Assert.That(o.AddressCount, Is.EqualTo(3)); + Assert.That(o.addresses, Is.EqualTo(new[] { "a:9000", "b:9000", "c:9000" })); + } + + [Test] + public void Parse_EmptyCommaPieceInAddr_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=a:9000,,b:9000;")); + Assert.Throws(() => new QueryOptions("ws::addr=,b:9000;")); + Assert.Throws(() => new QueryOptions("ws::addr=a:9000,;")); + } + + [Test] + public void ProgrammaticAddr_CommaSplits_RebuildsAddresses() + { + var o = new QueryOptions { addr = "a:9000,b:9000,c:9000" }; + Assert.That(o.addr, Is.EqualTo("a:9000")); + Assert.That(o.AddressCount, Is.EqualTo(3)); + Assert.That(o.addresses, Is.EqualTo(new[] { "a:9000", "b:9000", "c:9000" })); + } + + [Test] + public void ProgrammaticAddr_SingleEntry_KeepsSingletonAddresses() + { + var o = new QueryOptions { addr = "host:9000" }; + Assert.That(o.AddressCount, Is.EqualTo(1)); + Assert.That(o.addresses, Is.EqualTo(new[] { "host:9000" })); + } + + [Test] + public void ProgrammaticAddr_EmptyCommaPiece_Rejected() + { + Assert.Throws(() => new QueryOptions { addr = "a:1,,b:2" }); + } + + [Test] + public void Parse_MaxBatchRowsOutOfRange_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;max_batch_rows=-1;")); + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;max_batch_rows=1048577;")); + } + + [Test] + public void Parse_MaxBatchRowsOmitted_DefaultsToZeroForServerDefault() + { + var o = new QueryOptions("ws::addr=h:9000;"); + Assert.That(o.max_batch_rows, Is.EqualTo(0)); + } + + [TestCase("username=u;password=p;token=t;")] + public void Parse_AuthMutex_Rejected(string params_) + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;" + params_)); + } + + [Test] + public void Parse_UsernameWithoutPassword_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;username=u;")); + } + + [Test] + public void Parse_PasswordWithoutUsername_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;password=p;")); + } + + [Test] + public void Parse_AuthKey_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;auth=Bearer xyz;")); + } + + [Test] + public void Parse_BasicAuth_Accepted() + { + var o = new QueryOptions("ws::addr=h:9000;username=u;password=p;"); + Assert.That(o.username, Is.EqualTo("u")); + Assert.That(o.password, Is.EqualTo("p")); + } + + [Test] + public void Parse_BearerToken_Accepted() + { + var o = new QueryOptions("ws::addr=h:9000;token=tok;"); + Assert.That(o.token, Is.EqualTo("tok")); + } + + [Test] + public void Parse_WssWithCustomRoots_Accepted() + { + var o = new QueryOptions("wss::addr=h:443;tls_roots=/p.pem;tls_roots_password=s;"); + Assert.That(o.tls_roots, Is.EqualTo("/p.pem")); + Assert.That(o.tls_roots_password, Is.EqualTo("s")); + } + + [Test] + public void Parse_WsWithTlsVerifyOff_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;tls_verify=unsafe_off;")); + } + + [Test] + public void Parse_WsWithTlsRoots_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;tls_roots=/p.pem;")); + } + + [Test] + public void Parse_TlsRootsPasswordWithoutRoots_Rejected() + { + Assert.Throws(() => + new QueryOptions("wss::addr=h:443;tls_roots_password=secret;")); + } + + [TestCase("compression=raw;", CompressionType.raw)] + [TestCase("compression=zstd;", CompressionType.zstd)] + [TestCase("compression=auto;", CompressionType.auto)] + public void Parse_CompressionValues_Accepted(string params_, CompressionType expected) + { + var o = new QueryOptions("ws::addr=h:9000;" + params_); + Assert.That(o.compression, Is.EqualTo(expected)); + } + + [Test] + public void Parse_BadCompression_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;compression=lz4;")); + } + + [TestCase(0)] + [TestCase(-1)] + [TestCase(23)] + [TestCase(100)] + public void Parse_BadCompressionLevel_Rejected(int level) + { + Assert.Throws(() => new QueryOptions( + $"ws::addr=h:9000;compression=auto;compression_level={level};")); + } + + [TestCase(1)] + [TestCase(5)] + [TestCase(9)] + public void Parse_CompressionLevelInRange_Accepted(int level) + { + Assert.DoesNotThrow(() => new QueryOptions( + $"ws::addr=h:9000;compression_level={level};")); + } + + [Test] + public void Parse_CompressionRaw_IgnoresOutOfRangeLevel() + { + Assert.DoesNotThrow(() => new QueryOptions( + "ws::addr=h:9000;compression=raw;compression_level=99;")); + } + + [Test] + public void Parse_BufferPoolSize_AcceptedForCrossClientInterop() + { + Assert.DoesNotThrow(() => new QueryOptions("ws::addr=h:9000;buffer_pool_size=4;")); + } + + [Test] + public void Parse_IngressOnlyKeys_AcceptedAndIgnoredOnEgress() + { + Assert.DoesNotThrow(() => new QueryOptions( + "ws::addr=h:9000;sf_dir=/tmp/x;auto_flush_rows=5000;" + + "reconnect_max_backoff_millis=3000;drain_orphans=on;request_durable_ack=on;" + + "on_server_error=halt;error_inbox_capacity=64;")); + } + + [Test] + public void ConnectString_WithBothIngressAndEgressKeys_ParsesOnBothClients() + { + const string shared = + "ws::addr=localhost:9000;username=admin;password=secret;" + + "sf_dir=/tmp/qdb;auto_flush_rows=5000;reconnect_max_backoff_millis=3000;" + + "compression=zstd;failover=on;max_batch_rows=10000;target=replica;"; + + var sender = new SenderOptions(shared); + Assert.That(sender.sf_dir, Is.EqualTo("/tmp/qdb")); + Assert.That(sender.auto_flush_rows, Is.EqualTo(5000)); + + var query = new QueryOptions(shared); + Assert.That(query.compression, Is.EqualTo(CompressionType.zstd)); + Assert.That(query.target, Is.EqualTo(TargetType.replica)); + } + + [Test] + public void ConnectString_WithFullUnionOfBothSidesKeys_ParsesOnBothClients() + { + // The legacy ILP HTTP/TCP keys (protocol_version, request_timeout, retry_timeout, + // request_min_throughput, gzip, pool_timeout, own_socket, auth_timeout, token_x/y) are + // not part of the QWP vocabulary and are rejected on ws::; this string carries only QWP + // keys from both directions, so both QWP clients accept it. + const string shared = + "ws::addr=localhost:9000;user=admin;pass=secret;" + + "auth_timeout_ms=15000;init_buf_size=65536;max_buf_size=1048576;max_name_len=127;" + + "sf_dir=/tmp/qdb;auto_flush_rows=5000;reconnect_max_backoff_millis=3000;" + + "ping_timeout=2000;transaction=off;error_inbox_capacity=64;on_parse_error=halt;" + + "compression=zstd;failover=on;max_batch_rows=10000;target=replica;path=/read/v1;"; + + Assert.DoesNotThrow(() => _ = new SenderOptions(shared)); + Assert.DoesNotThrow(() => _ = new QueryOptions(shared)); + } + + [TestCase("protocol_version=2")] + [TestCase("protocol_version=auto")] + [TestCase("request_timeout=5000")] + [TestCase("retry_timeout=10000")] + [TestCase("request_min_throughput=1024")] + [TestCase("gzip=on")] + [TestCase("pool_timeout=30000")] + [TestCase("own_socket=on")] + [TestCase("token_x=abc")] + [TestCase("token_y=def")] + [TestCase("auth_timeout=15000")] + public void Parse_IlpHttpOnlyKey_RejectedOnWs(string keyValue) + { + var ex = Assert.Throws(() => + new QueryOptions($"ws::addr=h:9000;{keyValue};")); + Assert.That(ex!.Message, Does.Contain("not supported for QWP/WebSocket transport")); + } + + [TestCase("ws", "max_datagram_size=2048")] + [TestCase("ws", "multicast_ttl=4")] + [TestCase("wss", "max_datagram_size=2048")] + [TestCase("wss", "multicast_ttl=4")] + public void Parse_UdpOnlyKey_RejectedOnWs(string scheme, string keyValue) + { + // max_datagram_size / multicast_ttl are UDP-only ingress knobs with no transport in this + // client. They are absent from the QWP connect-string vocabulary, so the egress parser + // rejects them rather than silently consuming them. + var key = keyValue.Substring(0, keyValue.IndexOf('=')); + var ex = Assert.Throws(() => + new QueryOptions($"{scheme}::addr=h:9000;{keyValue};")); + Assert.That(ex!.Message, Does.Contain(key)); + } + + [TestCase("Addr")] + [TestCase("ADDR")] + [TestCase("AdDr")] + public void Parse_AddrKeyIsCaseInsensitive(string keyForm) + { + var o = new QueryOptions($"ws::{keyForm}=h1:9000,h2:9000;"); + Assert.That(o.AddressCount, Is.EqualTo(2)); + Assert.That(o.addresses[0], Is.EqualTo("h1:9000")); + Assert.That(o.addresses[1], Is.EqualTo("h2:9000")); + } + + [TestCase("target=any;", TargetType.any)] + [TestCase("target=primary;", TargetType.primary)] + [TestCase("target=replica;", TargetType.replica)] + public void Parse_TargetValues_Accepted(string params_, TargetType expected) + { + var o = new QueryOptions("ws::addr=h:9000;" + params_); + Assert.That(o.target, Is.EqualTo(expected)); + } + + [Test] + public void Parse_BadTarget_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;target=arbiter;")); + } + + [Test] + public void Parse_FailoverOff() + { + var o = new QueryOptions("ws::addr=h:9000;failover=off;"); + Assert.That(o.failover, Is.False); + } + + [Test] + public void Parse_FailoverInitialGtMax_Rejected() + { + Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;failover_backoff_initial_ms=2000;failover_backoff_max_ms=500;")); + } + + [Test] + public void Parse_FailoverBackoffInitialZero_Rejected() + { + // EnsureValid must reject 0 here because QwpReconnectPolicy ctor rejects it; without this + // we'd surface the throw deep inside the first Execute() attempt instead of at config-load. + var ex = Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;failover_backoff_initial_ms=0;")); + Assert.That(ex!.Message, Does.Contain("failover_backoff_initial_ms")); + } + + [Test] + public void Parse_FailoverBackoffMaxZero_Rejected() + { + var ex = Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;failover_backoff_max_ms=0;")); + Assert.That(ex!.Message, Does.Contain("failover_backoff_max_ms")); + } + + [Test] + public void Parse_FailoverMaxAttemptsZero_Rejected() + { + Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;failover_max_attempts=0;")); + } + + [Test] + public void Parse_MaxBatchRowsZero_Rejected() + { + Assert.Throws(() => new QueryOptions("ws::addr=h:9000;max_batch_rows=0;")); + } + + [Test] + public void Parse_MaxBatchRowsNegative_Rejected() + { + Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;max_batch_rows=-5;")); + } + + [Test] + public void Parse_PathOverride_TakesEffect() + { + var o = new QueryOptions("ws::addr=h:9000;path=/read/v2;"); + Assert.That(o.path, Is.EqualTo("/read/v2")); + } + + [Test] + public void Parse_ClientId_TakesEffect() + { + var o = new QueryOptions("ws::addr=h:9000;client_id=net-client/1.0;"); + Assert.That(o.client_id, Is.EqualTo("net-client/1.0")); + } + + [Test] + public void Parse_TokenWithControlChar_Rejected() + { + Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;token=abc\rdef;")); + } + + [Test] + public void EnsureValid_Programmatic_DefaultsPass() + { + var o = new QueryOptions(); + Assert.DoesNotThrow(() => o.EnsureValid()); + } + + [Test] + public void EnsureValid_Programmatic_BadCompressionLevelCaught() + { + var o = new QueryOptions { compression = CompressionType.auto, compression_level = 0 }; + Assert.Throws(() => o.EnsureValid()); + } + + [Test] + public void EnsureValid_Programmatic_AuthMutexCaught() + { + var o = new QueryOptions { username = "u", password = "p", token = "t" }; + Assert.Throws(() => o.EnsureValid()); + } + + [Test] + public void EnsureValid_FailoverTimeouts_AboveTaskDelayCeiling_Rejected() + { + // The failover loop sleeps via Task.Delay (shared QwpReconnectPolicy), which throws once the + // delay exceeds ~int.MaxValue ms. The config string is int-parsed so it can't reach this, but + // the programmatic TimeSpan setters can — EnsureValid must reject them at config-load. + var overCeiling = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); + + var max = new QueryOptions { failover_backoff_max_ms = overCeiling }; + Assert.That(() => max.EnsureValid(), + Throws.TypeOf().With.Message.Contains("failover_backoff_max_ms")); + + var dur = new QueryOptions { failover_max_duration_ms = overCeiling }; + Assert.That(() => dur.EnsureValid(), + Throws.TypeOf().With.Message.Contains("failover_max_duration_ms")); + + // initial must stay <= max, so raise both; the initial ceiling check fires first. + var init = new QueryOptions { failover_backoff_initial_ms = overCeiling, failover_backoff_max_ms = overCeiling }; + Assert.That(() => init.EnsureValid(), + Throws.TypeOf().With.Message.Contains("failover_backoff_initial_ms")); + + // Exactly at the ceiling stays valid. + var atCeiling = new QueryOptions { failover_backoff_max_ms = TimeSpan.FromMilliseconds(int.MaxValue) }; + Assert.That(() => atCeiling.EnsureValid(), Throws.Nothing); + } + + [Test] + public void TokenXY_RejectedOnWs() + { + // token_x / token_y are legacy ILP/TCP ECDSA key components, not QWP keys; the egress + // query client is QWP-only, so it rejects them rather than silently consuming them. + var ex = Assert.Throws( + () => new QueryOptions("ws::addr=localhost:9000;token_x=abc;token_y=def;")); + Assert.That(ex!.Message, Does.Contain("not supported for QWP/WebSocket transport")); + } + + [Test] + public void OtherDirectionIngressKey_AcceptedAndIgnored() + { + // A ws:: string is shared between the Sender and the QwpQueryClient. An ingress-only + // QWP key (here a .NET-specific WebSocket ingress knob) is silently consumed by the + // egress parser so the same string drives both clients. + Assert.That( + () => new QueryOptions("ws::addr=localhost:9000;ping_timeout=2000;proxy=http://h:8080;"), + Throws.Nothing); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpBindValuesTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpBindValuesTests.cs new file mode 100644 index 00000000..8f736ea2 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpBindValuesTests.cs @@ -0,0 +1,236 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Query; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QwpBindValuesTests +{ + [Test] + public void Empty_HasNoBytes() + { + var b = new QwpBindValues(); + Assert.That(b.Count, Is.EqualTo(0)); + Assert.That(b.AsMemory().Length, Is.EqualTo(0)); + } + + [Test] + public void SetLong_OneRow_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetLong(0, 42L); + + Assert.That(b.Count, Is.EqualTo(1)); + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes, Is.EqualTo(new byte[] + { + (byte)QwpTypeCode.Long, 0x00, // type, null_flag = 0 + 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 42 as i64 LE + })); + } + + [Test] + public void SetNull_Long_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetNull(0, QwpTypeCode.Long); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes, Is.EqualTo(new byte[] { (byte)QwpTypeCode.Long, 0x01, 0x01 })); + } + + [Test] + public void SetVarchar_AsciiValue_PinnedLayout() + { + var b = new QwpBindValues(); + b.SetVarchar(0, "hi"); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes, Is.EqualTo(new byte[] + { + (byte)QwpTypeCode.Varchar, 0x00, // type, null_flag = 0 + 0x00, 0x00, 0x00, 0x00, // offset[0] = 0 + 0x02, 0x00, 0x00, 0x00, // offset[1] = 2 + (byte)'h', (byte)'i', + })); + } + + [Test] + public void SetVarchar_NullValue_EncodedAsTypedNull() + { + var b = new QwpBindValues(); + b.SetVarchar(0, null); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes, Is.EqualTo(new byte[] { (byte)QwpTypeCode.Varchar, 0x01, 0x01 })); + } + + [Test] + public void SetDecimal128_NonNull_HasScalePrefix() + { + var b = new QwpBindValues(); + b.SetDecimal128(0, scale: 4, lo: 1234567890L, hi: 0L); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes[0], Is.EqualTo((byte)QwpTypeCode.Decimal128)); + Assert.That(bytes[1], Is.EqualTo(0x00)); + Assert.That(bytes[2], Is.EqualTo((byte)4)); + } + + [Test] + public void SetNullDecimal128_HasScaleAfterBitmap() + { + var b = new QwpBindValues(); + b.SetNullDecimal128(0, scale: 6); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes, Is.EqualTo(new byte[] { (byte)QwpTypeCode.Decimal128, 0x01, 0x01, 0x06 })); + } + + [Test] + public void SetGeohash_HasPrecisionVarint_AndPackedBytes() + { + var b = new QwpBindValues(); + b.SetGeohash(0, precisionBits: 24, value: 0xABCDEF); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes[0], Is.EqualTo((byte)QwpTypeCode.Geohash)); + Assert.That(bytes[1], Is.EqualTo(0x00)); + Assert.That(bytes[2], Is.EqualTo((byte)24)); + Assert.That(bytes[3], Is.EqualTo(0xEF)); + Assert.That(bytes[4], Is.EqualTo(0xCD)); + Assert.That(bytes[5], Is.EqualTo(0xAB)); + } + + [Test] + public void SetUuid_HighLowAreLittleEndian_LowFirst() + { + var b = new QwpBindValues(); + b.SetUuid(0, lo: 0x0102030405060708L, hi: 0x1112131415161718L); + + var bytes = b.AsMemory().ToArray(); + Assert.That(bytes[0], Is.EqualTo((byte)QwpTypeCode.Uuid)); + Assert.That(bytes[1], Is.EqualTo(0x00)); + Assert.That(bytes[2..10], Is.EqualTo(new byte[] { 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 })); + Assert.That(bytes[10..18], Is.EqualTo(new byte[] { 0x18, 0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11 })); + } + + [Test] + public void Index_OutOfOrder_Throws() + { + var b = new QwpBindValues(); + b.SetLong(0, 1); + Assert.Throws(() => b.SetLong(2, 2)); + } + + [Test] + public void Index_Repeated_Throws() + { + var b = new QwpBindValues(); + b.SetLong(0, 1); + Assert.Throws(() => b.SetLong(0, 2)); + } + + [Test] + public void Decimal64_ScaleOutOfRange_Throws() + { + var b = new QwpBindValues(); + Assert.Throws(() => b.SetDecimal64(0, scale: -1, unscaledValue: 0)); + Assert.Throws(() => b.SetDecimal64(0, scale: 19, unscaledValue: 0)); + } + + [Test] + public void Decimal128_ScaleOutOfRange_Throws() + { + var b = new QwpBindValues(); + Assert.Throws(() => b.SetDecimal128(0, scale: 39, lo: 0, hi: 0)); + } + + [Test] + public void Decimal256_ScaleOutOfRange_Throws() + { + var b = new QwpBindValues(); + Assert.Throws(() => b.SetDecimal256(0, scale: 77, ll: 0, lh: 0, hl: 0, hh: 0)); + } + + [Test] + public void Geohash_PrecisionOutOfRange_Throws() + { + var b = new QwpBindValues(); + Assert.Throws(() => b.SetGeohash(0, precisionBits: 0, value: 1)); + Assert.Throws(() => b.SetGeohash(0, precisionBits: 61, value: 1)); + } + + [Test] + public void SetNull_ForUnsupportedType_Throws() + { + var b = new QwpBindValues(); + Assert.Throws(() => b.SetNull(0, QwpTypeCode.Symbol)); + } + + [Test] + public void Reset_ClearsBuffer() + { + var b = new QwpBindValues(); + b.SetLong(0, 1); + b.SetVarchar(1, "x"); + Assert.That(b.Count, Is.EqualTo(2)); + + b.Reset(); + Assert.That(b.Count, Is.EqualTo(0)); + Assert.That(b.AsMemory().Length, Is.EqualTo(0)); + + b.SetLong(0, 99); + Assert.That(b.Count, Is.EqualTo(1)); + } + + [Test] + public void MultipleBinds_IncrementsCount_AndAppends() + { + var b = new QwpBindValues(); + b.SetInt(0, 7); + b.SetVarchar(1, "abc"); + b.SetLong(2, 999); + + Assert.That(b.Count, Is.EqualTo(3)); + Assert.That(b.AsMemory().Length, Is.GreaterThan(0)); + } + + [Test] + public void TooManyBinds_RejectedAtBoundary() + { + var b = new QwpBindValues(); + for (var i = 0; i < QuestDB.Qwp.QwpConstants.MaxBindParameters; i++) + { + b.SetInt(i, i); + } + Assert.Throws( + () => b.SetInt(QuestDB.Qwp.QwpConstants.MaxBindParameters, 0)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpBindValuesVectorsTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpBindValuesVectorsTests.cs new file mode 100644 index 00000000..6d1076e3 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpBindValuesVectorsTests.cs @@ -0,0 +1,659 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Text; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Query; + +namespace net_questdb_client_tests.Qwp.Query; + +/// +/// Pinned byte-exact bind-payload vectors. Hand-rolled little-endian expected bytes per +/// wire type; any drift here is a wire-format regression that breaks egress interop. +/// +[TestFixture] +public class QwpBindValuesVectorsTests +{ + private const byte NonNull = 0x00; + private const byte NullFlag = 0x01; + private const byte NullBitmap = 0x01; + + private const byte TypeBoolean = 0x01; + private const byte TypeByte = 0x02; + private const byte TypeShort = 0x03; + private const byte TypeInt = 0x04; + private const byte TypeLong = 0x05; + private const byte TypeFloat = 0x06; + private const byte TypeDouble = 0x07; + private const byte TypeTimestamp = 0x0A; + private const byte TypeDate = 0x0B; + private const byte TypeUuid = 0x0C; + private const byte TypeLong256 = 0x0D; + private const byte TypeGeohash = 0x0E; + private const byte TypeVarchar = 0x0F; + private const byte TypeTimestampNanos = 0x10; + private const byte TypeDecimal64 = 0x13; + private const byte TypeDecimal128 = 0x14; + private const byte TypeDecimal256 = 0x15; + private const byte TypeChar = 0x16; + + [Test] + public void EncodeBoolean_True_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetBoolean(0, true); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeBoolean); + w.Add(NonNull); + w.Add(0x01); + })); + } + + [Test] + public void EncodeBoolean_False_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetBoolean(0, false); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeBoolean); + w.Add(NonNull); + w.Add(0x00); + })); + } + + [Test] + public void EncodeByte_BoundaryValues_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetByte(0, unchecked((byte)-128)); + b.SetByte(1, 0); + b.SetByte(2, 127); + AssertBytes(b, 3, BuildExpected(w => + { + w.Add(TypeByte); w.Add(NonNull); w.Add(0x80); + w.Add(TypeByte); w.Add(NonNull); w.Add(0x00); + w.Add(TypeByte); w.Add(NonNull); w.Add(0x7F); + })); + } + + [Test] + public void EncodeShort_BoundaryValues_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetShort(0, short.MinValue); + b.SetShort(1, 0); + b.SetShort(2, short.MaxValue); + AssertBytes(b, 3, BuildExpected(w => + { + w.Add(TypeShort); w.Add(NonNull); w.AddI16Le(short.MinValue); + w.Add(TypeShort); w.Add(NonNull); w.AddI16Le(0); + w.Add(TypeShort); w.Add(NonNull); w.AddI16Le(short.MaxValue); + })); + } + + [Test] + public void EncodeChar_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetChar(0, 'Z'); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeChar); + w.Add(NonNull); + w.AddU16Le('Z'); + })); + } + + [Test] + public void EncodeInt_BoundaryValues_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetInt(0, int.MinValue); + b.SetInt(1, 0); + b.SetInt(2, int.MaxValue); + AssertBytes(b, 3, BuildExpected(w => + { + w.Add(TypeInt); w.Add(NonNull); w.AddI32Le(int.MinValue); + w.Add(TypeInt); w.Add(NonNull); w.AddI32Le(0); + w.Add(TypeInt); w.Add(NonNull); w.AddI32Le(int.MaxValue); + })); + } + + [Test] + public void EncodeLong_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetLong(0, 42L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeLong); + w.Add(NonNull); + w.AddI64Le(42L); + })); + } + + [Test] + public void EncodeFloat_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetFloat(0, 3.14f); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeFloat); + w.Add(NonNull); + w.AddI32Le(BitConverter.SingleToInt32Bits(3.14f)); + })); + } + + [Test] + public void EncodeDouble_FiniteAndNaN_PinnedBytes() + { + var b1 = new QwpBindValues(); + b1.SetDouble(0, 2.718281828); + AssertBytes(b1, 1, BuildExpected(w => + { + w.Add(TypeDouble); + w.Add(NonNull); + w.AddI64Le(BitConverter.DoubleToInt64Bits(2.718281828)); + })); + + var b2 = new QwpBindValues(); + b2.SetDouble(0, double.NaN); + AssertBytes(b2, 1, BuildExpected(w => + { + w.Add(TypeDouble); + w.Add(NonNull); + w.AddI64Le(BitConverter.DoubleToInt64Bits(double.NaN)); + })); + } + + [Test] + public void EncodeDate_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetDate(0, 1_700_000_000_000L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDate); + w.Add(NonNull); + w.AddI64Le(1_700_000_000_000L); + })); + } + + [Test] + public void EncodeTimestampMicros_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetTimestampMicros(0, 1_700_000_000_000_000L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeTimestamp); + w.Add(NonNull); + w.AddI64Le(1_700_000_000_000_000L); + })); + } + + [Test] + public void EncodeTimestampNanos_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetTimestampNanos(0, 1_700_000_000_000_000_000L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeTimestampNanos); + w.Add(NonNull); + w.AddI64Le(1_700_000_000_000_000_000L); + })); + } + + [Test] + public void EncodeUuid_ExplicitLimbs_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetUuid(0, unchecked((long)0xFEEDFACECAFEBEEFUL), unchecked((long)0x0BADF00DDEADBEEFUL)); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeUuid); + w.Add(NonNull); + w.AddI64Le(unchecked((long)0xFEEDFACECAFEBEEFUL)); + w.AddI64Le(unchecked((long)0x0BADF00DDEADBEEFUL)); + })); + } + + [Test] + public void EncodeUuid_FromGuid_PinnedBytes() + { + var guid = Guid.Parse("123e4567-e89b-12d3-a456-426614174000"); + var b = new QwpBindValues(); + b.SetUuid(0, guid); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeUuid); + w.Add(NonNull); + w.AddI64Le(unchecked((long)0xA456426614174000UL)); + w.AddI64Le(unchecked((long)0x123E4567E89B12D3UL)); + })); + } + + [Test] + public void EncodeUuid_FromGuid_AscendingNibbles_PinnedBytes() + { + var guid = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + var b = new QwpBindValues(); + b.SetUuid(0, guid); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeUuid); + w.Add(NonNull); + w.AddI64Le(unchecked((long)0x8899AABBCCDDEEFFUL)); + w.AddI64Le(unchecked((long)0x0011223344556677UL)); + })); + } + + [Test] + public void EncodeLong256_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetLong256(0, + unchecked((long)0x1111111111111111UL), + unchecked((long)0x2222222222222222UL), + unchecked((long)0x3333333333333333UL), + unchecked((long)0x4444444444444444UL)); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeLong256); + w.Add(NonNull); + w.AddI64Le(unchecked((long)0x1111111111111111UL)); + w.AddI64Le(unchecked((long)0x2222222222222222UL)); + w.AddI64Le(unchecked((long)0x3333333333333333UL)); + w.AddI64Le(unchecked((long)0x4444444444444444UL)); + })); + } + + [Test] + public void EncodeGeohash_MaxPrecision_PinnedBytes() + { + const long value = 0x0FFF_FFFF_FFFF_FFFFL; + var b = new QwpBindValues(); + b.SetGeohash(0, 60, value); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeGeohash); + w.Add(NonNull); + w.AddVarint(60); + for (var i = 0; i < 8; i++) w.Add((byte)(value >>> (i * 8))); + })); + } + + [Test] + public void EncodeGeohash_MinPrecision_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetGeohash(0, 1, 1L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeGeohash); + w.Add(NonNull); + w.AddVarint(1); + w.Add(0x01); + })); + } + + [Test] + public void EncodeGeohash_MasksHighBitsForSubByteprecision_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetGeohash(0, 5, 0xFFL); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeGeohash); + w.Add(NonNull); + w.AddVarint(5); + w.Add(0x1F); + })); + } + + [Test] + public void EncodeGeohash_MasksHighBitsAtMaxPrecision_PinnedBytes() + { + const long expectedValue = (1L << 60) - 1L; + var b = new QwpBindValues(); + b.SetGeohash(0, 60, -1L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeGeohash); + w.Add(NonNull); + w.AddVarint(60); + for (var i = 0; i < 8; i++) w.Add((byte)(expectedValue >>> (i * 8))); + })); + } + + [Test] + public void EncodeDecimal64_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetDecimal64(0, 2, 12345L); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDecimal64); + w.Add(NonNull); + w.Add(0x02); + w.AddI64Le(12345L); + })); + } + + [Test] + public void EncodeDecimal128_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetDecimal128(0, 6, + unchecked((long)0x0123456789ABCDEFUL), + unchecked((long)0x7766554433221100UL)); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDecimal128); + w.Add(NonNull); + w.Add(0x06); + w.AddI64Le(unchecked((long)0x0123456789ABCDEFUL)); + w.AddI64Le(unchecked((long)0x7766554433221100UL)); + })); + } + + [Test] + public void EncodeDecimal256_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetDecimal256(0, 10, + unchecked((long)0x1111111111111111UL), + unchecked((long)0x2222222222222222UL), + unchecked((long)0x3333333333333333UL), + unchecked((long)0x4444444444444444UL)); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDecimal256); + w.Add(NonNull); + w.Add(0x0A); + w.AddI64Le(unchecked((long)0x1111111111111111UL)); + w.AddI64Le(unchecked((long)0x2222222222222222UL)); + w.AddI64Le(unchecked((long)0x3333333333333333UL)); + w.AddI64Le(unchecked((long)0x4444444444444444UL)); + })); + } + + [Test] + public void EncodeVarchar_Ascii_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetVarchar(0, "hello"); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeVarchar); + w.Add(NonNull); + w.AddI32Le(0); + w.AddI32Le(5); + foreach (var x in Encoding.UTF8.GetBytes("hello")) w.Add(x); + })); + } + + [Test] + public void EncodeVarchar_Empty_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetVarchar(0, ""); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeVarchar); + w.Add(NonNull); + w.AddI32Le(0); + w.AddI32Le(0); + })); + } + + [Test] + public void EncodeVarchar_Null_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetVarchar(0, null); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeVarchar); + w.Add(NullFlag); + w.Add(NullBitmap); + })); + } + + [Test] + public void EncodeVarchar_Unicode_PinnedBytes() + { + const string value = "café"; + var utf8 = Encoding.UTF8.GetBytes(value); + var b = new QwpBindValues(); + b.SetVarchar(0, value); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeVarchar); + w.Add(NonNull); + w.AddI32Le(0); + w.AddI32Le(utf8.Length); + foreach (var x in utf8) w.Add(x); + })); + } + + [Test] + public void EncodeNull_Scalar_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetNull(0, QwpTypeCode.Long); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeLong); + w.Add(NullFlag); + w.Add(NullBitmap); + })); + } + + [Test] + public void EncodeNullDecimal64_WithExplicitScale_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetNullDecimal64(0, 3); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDecimal64); + w.Add(NullFlag); + w.Add(NullBitmap); + w.Add(0x03); + })); + } + + [Test] + public void EncodeNullDecimal128_WithExplicitScale_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetNullDecimal128(0, 12); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDecimal128); + w.Add(NullFlag); + w.Add(NullBitmap); + w.Add(0x0C); + })); + } + + [Test] + public void EncodeNullDecimal256_WithExplicitScale_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetNullDecimal256(0, 50); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeDecimal256); + w.Add(NullFlag); + w.Add(NullBitmap); + w.Add(0x32); + })); + } + + [Test] + public void EncodeNullGeohash_WithExplicitPrecision_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetNullGeohash(0, 40); + AssertBytes(b, 1, BuildExpected(w => + { + w.Add(TypeGeohash); + w.Add(NullFlag); + w.Add(NullBitmap); + w.AddVarint(40); + })); + } + + [Test] + public void EncodeNullTypes_Exhaustive_PinnedBytes() + { + var types = new[] + { + QwpTypeCode.Boolean, QwpTypeCode.Byte, QwpTypeCode.Short, QwpTypeCode.Char, + QwpTypeCode.Int, QwpTypeCode.Long, QwpTypeCode.Float, QwpTypeCode.Double, + QwpTypeCode.Date, QwpTypeCode.Timestamp, QwpTypeCode.TimestampNanos, + QwpTypeCode.Uuid, QwpTypeCode.Long256, QwpTypeCode.Geohash, QwpTypeCode.Varchar, + QwpTypeCode.Decimal64, QwpTypeCode.Decimal128, QwpTypeCode.Decimal256, + }; + + var b = new QwpBindValues(); + for (var i = 0; i < types.Length; i++) b.SetNull(i, types[i]); + + var expected = BuildExpected(w => + { + foreach (var t in types) + { + w.Add((byte)t); + w.Add(NullFlag); + w.Add(NullBitmap); + if (t is QwpTypeCode.Decimal64 or QwpTypeCode.Decimal128 or QwpTypeCode.Decimal256) + w.Add(0x00); + else if (t is QwpTypeCode.Geohash) + w.AddVarint(1); + } + }); + AssertBytes(b, types.Length, expected); + } + + [Test] + public void EncodeMultiBind_MixedTypes_PinnedBytes() + { + var b = new QwpBindValues(); + b.SetLong(0, 1234567890L); + b.SetVarchar(1, "hello"); + b.SetBoolean(2, true); + b.SetDouble(3, 1.5); + + AssertBytes(b, 4, BuildExpected(w => + { + w.Add(TypeLong); w.Add(NonNull); w.AddI64Le(1234567890L); + + w.Add(TypeVarchar); w.Add(NonNull); w.AddI32Le(0); w.AddI32Le(5); + foreach (var x in Encoding.UTF8.GetBytes("hello")) w.Add(x); + + w.Add(TypeBoolean); w.Add(NonNull); w.Add(0x01); + + w.Add(TypeDouble); w.Add(NonNull); + w.AddI64Le(BitConverter.DoubleToInt64Bits(1.5)); + })); + } + + [Test] + public void Reset_ProducesIdenticalBytes() + { + var b = new QwpBindValues(); + b.SetLong(0, 42L); + b.SetInt(1, 7); + var first = b.AsMemory().ToArray(); + + b.Reset(); + Assert.That(b.Count, Is.EqualTo(0)); + Assert.That(b.AsMemory().Length, Is.EqualTo(0)); + + b.SetLong(0, 42L); + b.SetInt(1, 7); + Assert.That(b.AsMemory().ToArray(), Is.EqualTo(first)); + } + + private static void AssertBytes(QwpBindValues binds, int expectedCount, byte[] expected) + { + Assert.That(binds.Count, Is.EqualTo(expectedCount)); + Assert.That(binds.AsMemory().ToArray(), Is.EqualTo(expected)); + } + + private static byte[] BuildExpected(Action body) + { + var w = new ByteList(); + body(w); + return w.ToArray(); + } + + private sealed class ByteList + { + private readonly List _bytes = new(); + + public void Add(byte b) => _bytes.Add(b); + + public void AddI16Le(short v) + { + _bytes.Add((byte)(v & 0xFF)); + _bytes.Add((byte)((v >> 8) & 0xFF)); + } + + public void AddU16Le(ushort v) + { + _bytes.Add((byte)(v & 0xFF)); + _bytes.Add((byte)((v >> 8) & 0xFF)); + } + + public void AddI32Le(int v) + { + for (var i = 0; i < 4; i++) _bytes.Add((byte)((v >> (i * 8)) & 0xFF)); + } + + public void AddI64Le(long v) + { + for (var i = 0; i < 8; i++) _bytes.Add((byte)((v >> (i * 8)) & 0xFF)); + } + + public void AddVarint(ulong v) + { + while (v > 0x7F) + { + _bytes.Add((byte)((v & 0x7F) | 0x80)); + v >>= 7; + } + _bytes.Add((byte)(v & 0x7F)); + } + + public byte[] ToArray() => _bytes.ToArray(); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpEgressBoundsFuzzTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpEgressBoundsFuzzTests.cs new file mode 100644 index 00000000..55d0237e --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpEgressBoundsFuzzTests.cs @@ -0,0 +1,379 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Query; + +/// Bounds-check fuzz for the QWP egress RESULT_BATCH decoder; port of qwp_egress_bounds_fuzz.rs. +[TestFixture] +public class QwpEgressBoundsFuzzTests +{ + private const int Iterations = 50; + private const int CorruptionsPerMessage = 30; + + private static readonly QwpTypeCode[] FuzzableKinds = + { + QwpTypeCode.Boolean, QwpTypeCode.Byte, QwpTypeCode.Short, QwpTypeCode.Char, + QwpTypeCode.Int, QwpTypeCode.Long, QwpTypeCode.Float, QwpTypeCode.Double, + QwpTypeCode.Date, QwpTypeCode.Uuid, QwpTypeCode.Long256, QwpTypeCode.IPv4, + QwpTypeCode.Timestamp, QwpTypeCode.TimestampNanos, + QwpTypeCode.Varchar, QwpTypeCode.Binary, QwpTypeCode.Symbol, QwpTypeCode.Geohash, + QwpTypeCode.Decimal64, QwpTypeCode.Decimal128, QwpTypeCode.Decimal256, + QwpTypeCode.DoubleArray, + }; + + [Test] + public void TruncationAtEveryOffset() + { + var masterSeed = DeriveMasterSeed(); + TestContext.Progress.WriteLine($"QwpEgressBoundsFuzz.TruncationAtEveryOffset seed=0x{masterSeed:x16}"); + var master = new SplitMix64(masterSeed); + for (var iter = 0; iter < Iterations; iter++) + { + var seed = master.NextU64(); + try + { + var (payload, headerFlags) = GenerateValidMessage(seed); + SanityCheckDecode(payload, headerFlags); + for (var truncLen = 0; truncLen < payload.Length; truncLen++) + { + AttemptDecodeNoPanic(payload.AsSpan(0, truncLen), headerFlags); + } + } + catch (Exception ex) + { + throw new AssertionException( + $"truncation fuzz failure: masterSeed=0x{masterSeed:x16} iteration={iter} iterSeed=0x{seed:x16}", ex); + } + } + } + + [Test] + public void ByteCorruption() + { + var masterSeed = DeriveMasterSeed(); + TestContext.Progress.WriteLine($"QwpEgressBoundsFuzz.ByteCorruption seed=0x{masterSeed:x16}"); + var master = new SplitMix64(masterSeed); + for (var iter = 0; iter < Iterations; iter++) + { + var seed = master.NextU64(); + try + { + var (payload, headerFlags) = GenerateValidMessage(seed); + SanityCheckDecode(payload, headerFlags); + + var rng = new SplitMix64(seed ^ 0xDEAD_BEEF_DEAD_BEEFUL); + for (var c = 0; c < CorruptionsPerMessage; c++) + { + var corrupted = (byte[])payload.Clone(); + var nCorrupt = 1 + rng.GenRange(3); + for (var i = 0; i < nCorrupt; i++) + { + corrupted[rng.GenRange(corrupted.Length)] = rng.NextU8(); + } + AttemptDecodeNoPanic(corrupted, headerFlags); + } + } + catch (Exception ex) + { + throw new AssertionException( + $"corruption fuzz failure: masterSeed=0x{masterSeed:x16} iteration={iter} iterSeed=0x{seed:x16}", ex); + } + } + } + + private static void SanityCheckDecode(byte[] payload, byte headerFlags) + { + try + { + new QwpResultBatchDecoder(new QwpEgressConnState()) + .Decode(payload, headerFlags, new QwpColumnBatch()); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"generated message must decode cleanly (len={payload.Length})", ex); + } + } + + /// + /// The decoder must reject malformed input with a controlled exception; any other type + /// (a slice overrun, overflow, OOM) signals a missing bounds check and propagates. + /// + private static void AttemptDecodeNoPanic(ReadOnlySpan bytes, byte headerFlags) + { + try + { + new QwpResultBatchDecoder(new QwpEgressConnState()) + .Decode(bytes, headerFlags, new QwpColumnBatch()); + } + catch (QwpDecodeException) + { + } + catch (IngressError) + { + } + catch (DecoderFallbackException) + { + } + } + + private static (byte[] Payload, byte HeaderFlags) GenerateValidMessage(ulong seed) + { + var rng = new SplitMix64(seed); + var rowCount = rng.GenRange(20); + var colCount = 1 + rng.GenRange(6); + + var kinds = new QwpTypeCode[colCount]; + for (var i = 0; i < colCount; i++) + { + kinds[i] = FuzzableKinds[rng.GenRange(FuzzableKinds.Length)]; + } + + DeltaSymbolDict? dict = null; + var dictSize = 0; + if (Array.IndexOf(kinds, QwpTypeCode.Symbol) >= 0) + { + dictSize = 1 + rng.GenRange(8); + var entries = new List(dictSize); + for (var d = 0; d < dictSize; d++) + { + var entryLen = 1 + rng.GenRange(8); + var chars = new char[entryLen]; + for (var k = 0; k < entryLen; k++) + { + chars[k] = (char)('a' + rng.NextU8() % 26); + } + entries.Add(new string(chars)); + } + dict = new DeltaSymbolDict { DeltaStart = 0, Entries = entries }; + } + + var schema = new ResultSchema(); + for (var i = 0; i < colCount; i++) + { + schema.Columns.Add(new SchemaColumn($"c{i}", kinds[i])); + } + + var data = new ResultBatchData { RowCount = rowCount }; + for (var i = 0; i < colCount; i++) + { + data.Columns.Add(BuildColumnData(kinds[i], rowCount, dictSize, rng)); + } + + var frame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data, dict, gorillaEnabled: false); + var headerFlags = frame[QwpConstants.OffsetFlags]; + var payloadLen = (int)BinaryPrimitives.ReadUInt32LittleEndian( + frame.AsSpan(QwpConstants.OffsetPayloadLength, 4)); + var payload = frame.AsSpan(QwpConstants.HeaderSize, payloadLen).ToArray(); + return (payload, headerFlags); + } + + private static TestColumnData BuildColumnData(QwpTypeCode kind, int rowCount, int dictSize, SplitMix64 rng) + { + switch (kind) + { + case QwpTypeCode.Boolean: + return new FixedColumnData { DenseBytes = RandomBytes(rng, (rowCount + 7) >> 3) }; + case QwpTypeCode.Byte: + return new FixedColumnData { DenseBytes = RandomBytes(rng, rowCount) }; + case QwpTypeCode.Short: + case QwpTypeCode.Char: + return new FixedColumnData { DenseBytes = RandomBytes(rng, rowCount * 2) }; + } + + var (bitmap, nonNull) = Validity(rowCount, rng); + switch (kind) + { + case QwpTypeCode.Int: + case QwpTypeCode.Float: + case QwpTypeCode.IPv4: + return new FixedColumnData { NullBitmap = bitmap, DenseBytes = RandomBytes(rng, nonNull * 4) }; + case QwpTypeCode.Long: + case QwpTypeCode.Double: + return new FixedColumnData { NullBitmap = bitmap, DenseBytes = RandomBytes(rng, nonNull * 8) }; + case QwpTypeCode.Uuid: + return new FixedColumnData { NullBitmap = bitmap, DenseBytes = RandomBytes(rng, nonNull * 16) }; + case QwpTypeCode.Long256: + return new FixedColumnData { NullBitmap = bitmap, DenseBytes = RandomBytes(rng, nonNull * 32) }; + case QwpTypeCode.Date: + case QwpTypeCode.Timestamp: + case QwpTypeCode.TimestampNanos: + { + var values = new long[nonNull]; + for (var i = 0; i < nonNull; i++) values[i] = unchecked((long)rng.NextU64()); + return new TimestampColumnData { NullBitmap = bitmap, DenseValues = values }; + } + case QwpTypeCode.Varchar: + { + var values = new string[nonNull]; + for (var i = 0; i < nonNull; i++) values[i] = RandomAscii(rng, rng.GenRange(20)); + return new VarcharColumnData { NullBitmap = bitmap, DenseValues = values }; + } + case QwpTypeCode.Binary: + { + var values = new byte[nonNull][]; + for (var i = 0; i < nonNull; i++) values[i] = RandomBytes(rng, rng.GenRange(20)); + return new BinaryColumnData { NullBitmap = bitmap, DenseValues = values }; + } + case QwpTypeCode.Symbol: + { + var ids = new int[nonNull]; + for (var i = 0; i < nonNull; i++) ids[i] = rng.GenRange(dictSize); + return new SymbolColumnData { NullBitmap = bitmap, DenseDictIds = ids }; + } + case QwpTypeCode.Geohash: + { + var precision = 1 + rng.GenRange(QwpConstants.MaxGeohashPrecisionBits); + var stride = (precision + 7) >> 3; + return new GeohashColumnData + { + NullBitmap = bitmap, + PrecisionBits = precision, + DenseBytes = RandomBytes(rng, nonNull * stride), + }; + } + case QwpTypeCode.Decimal64: + return new DecimalColumnData + { + NullBitmap = bitmap, Scale = (byte)rng.GenRange(20), + DenseBytes = RandomBytes(rng, nonNull * QwpConstants.Decimal64SizeBytes), + }; + case QwpTypeCode.Decimal128: + return new DecimalColumnData + { + NullBitmap = bitmap, Scale = (byte)rng.GenRange(20), + DenseBytes = RandomBytes(rng, nonNull * QwpConstants.Decimal128SizeBytes), + }; + case QwpTypeCode.Decimal256: + return new DecimalColumnData + { + NullBitmap = bitmap, Scale = (byte)rng.GenRange(20), + DenseBytes = RandomBytes(rng, nonNull * QwpConstants.Decimal256SizeBytes), + }; + case QwpTypeCode.DoubleArray: + { + var arrays = new (int[] Shape, double[] Values)[nonNull]; + for (var i = 0; i < nonNull; i++) + { + var dim = 1 + rng.GenRange(3); + var values = new double[dim]; + for (var k = 0; k < dim; k++) + { + values[k] = BitConverter.Int64BitsToDouble(unchecked((long)rng.NextU64())); + } + arrays[i] = (new[] { dim }, values); + } + return new DoubleArrayColumnData { NullBitmap = bitmap, DenseArrays = arrays }; + } + default: + throw new InvalidOperationException($"FuzzableKinds contains an unhandled kind {kind}"); + } + } + + private static (byte[]? Bitmap, int NonNull) Validity(int rowCount, SplitMix64 rng) + { + if (rowCount == 0 || !rng.GenBool()) + { + return (null, rowCount); + } + + var nullCount = rng.GenRange(rowCount + 1); + var bitmap = new byte[(rowCount + 7) >> 3]; + var remaining = nullCount; + while (remaining > 0) + { + var pos = rng.GenRange(rowCount); + var bit = (byte)(1 << (pos & 7)); + if ((bitmap[pos >> 3] & bit) == 0) + { + bitmap[pos >> 3] |= bit; + remaining--; + } + } + return (bitmap, rowCount - nullCount); + } + + private static byte[] RandomBytes(SplitMix64 rng, int n) + { + var bytes = new byte[n]; + for (var i = 0; i < n; i++) bytes[i] = rng.NextU8(); + return bytes; + } + + private static string RandomAscii(SplitMix64 rng, int n) + { + var chars = new char[n]; + for (var i = 0; i < n; i++) chars[i] = (char)(0x20 + rng.NextU8() % 95); + return new string(chars); + } + + private static ulong DeriveMasterSeed() + { + var raw = Environment.GetEnvironmentVariable("QWP_FUZZ_SEED"); + if (!string.IsNullOrWhiteSpace(raw)) + { + raw = raw.Trim(); + return raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? Convert.ToUInt64(raw.Substring(2), 16) + : ulong.Parse(raw); + } + return unchecked((ulong)(DateTime.UtcNow.Ticks ^ ((long)Environment.TickCount << 32))); + } + + private sealed class SplitMix64 + { + private ulong _state; + + public SplitMix64(ulong seed) + { + _state = seed | 0x9E37_79B9_7F4A_7C15UL; + } + + public ulong NextU64() + { + unchecked + { + _state += 0x9E37_79B9_7F4A_7C15UL; + var z = _state; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9UL; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EBUL; + return z ^ (z >> 31); + } + } + + public byte NextU8() => (byte)NextU64(); + + public int GenRange(int bound) => (int)(NextU64() % (ulong)bound); + + public bool GenBool() => (NextU64() & 1) == 0; + } +} diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpEgressFragmentationFuzzTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpEgressFragmentationFuzzTests.cs new file mode 100644 index 00000000..63a2e387 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpEgressFragmentationFuzzTests.cs @@ -0,0 +1,190 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_tests.Qwp.Query; + +/// +/// Network-fragmentation fuzz for the QWP egress reader; port of qwp_egress_fragmentation_fuzz.rs. +/// Fragments at the WebSocket-frame layer (the .NET WS stack hides its socket) rather than at TCP. +/// +[TestFixture] +public class QwpEgressFragmentationFuzzTests +{ + [Test] + public async Task FragmentedBackToBackQueries() + { + var chunk = PickChunk(0x1234_5678_9ABC_DEF0UL); + const int rows = 200; + await using var server = await StartMockAsync(rows, chunk); + using var client = QueryClient.New(ConnString(server)); + for (var q = 0; q < 3; q++) + { + var (n, sum) = RunAndSum(client); + Assert.That(n, Is.EqualTo(rows), $"chunk={chunk} query={q} row_count drift"); + Assert.That(sum, Is.EqualTo(ExpectedSum(rows)), $"chunk={chunk} query={q} id_sum drift"); + } + } + + [Test] + public async Task FragmentedStreamingBigResult() + { + var chunk = PickChunk(0xDEAD_BEEF_CAFE_BABEUL); + const int rows = 2000; + await using var server = await StartMockAsync(rows, chunk); + using var client = QueryClient.New(ConnString(server)); + var (n, sum) = RunAndSum(client); + Assert.That(n, Is.EqualTo(rows), $"chunk={chunk} row_count drift"); + Assert.That(sum, Is.EqualTo(ExpectedSum(rows)), $"chunk={chunk} id_sum drift"); + } + + [Test] + public async Task SurvivesMicroFragments() + { + await using var server = await StartMockAsync(rows: 3, fragmentSize: 5); + using var client = QueryClient.New(ConnString(server)); + var (n, sum) = RunAndSum(client); + Assert.That(n, Is.EqualTo(3), "chunk=5 row_count drift"); + Assert.That(sum, Is.EqualTo(ExpectedSum(3)), "chunk=5 id_sum drift"); + } + + [Test] + public async Task UnchunkedBaseline() + { + await using var server = await StartMockAsync(rows: 5, fragmentSize: 0); + using var client = QueryClient.New(ConnString(server)); + var (n, sum) = RunAndSum(client); + Assert.That(n, Is.EqualTo(5)); + Assert.That(sum, Is.EqualTo(ExpectedSum(5))); + } + + private static async Task StartMockAsync(int rows, int fragmentSize) + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpRole.Standalone, + epoch: 0UL, + capabilities: 0, + serverWallNs: 0, + clusterId: string.Empty, + nodeId: "n1"); + + var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + OutgoingFragmentSize = fragmentSize, + FrameHandlerMulti = frame => + { + if (frame.Length < 9 || frame[0] != QwpConstants.MsgKindQueryRequest) + { + return Array.Empty(); + } + + var requestId = BinaryPrimitives.ReadInt64LittleEndian(frame.AsSpan(1, 8)); + return new[] { BuildBatch(requestId, rows), BuildEnd(requestId, rows) }; + }, + }); + await server.StartAsync(); + return server; + } + + private static (int RowCount, long IdSum) RunAndSum(IQwpQueryClient client) + { + var handler = new SummingHandler(); + client.Execute("select 1", handler); + return (handler.RowCount, handler.IdSum); + } + + private static byte[] BuildBatch(long requestId, int rowCount) + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("id", QwpTypeCode.Long) }, + }; + var dense = new byte[rowCount * 8]; + for (var i = 0; i < rowCount; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(i * 8, 8), i + 1L); + } + var data = new ResultBatchData + { + RowCount = rowCount, + Columns = { new FixedColumnData { DenseBytes = dense } }, + }; + return QwpEgressFrameBuilder.BuildResultBatch(requestId, 0L, schema, data); + } + + private static byte[] BuildEnd(long requestId, int rowCount) + => QwpEgressFrameBuilder.BuildResultEnd(requestId, 0L, rowCount); + + private static string ConnString(DummyQwpServer server) + => $"ws::addr={server.Uri.Authority};path={QwpConstants.ReadPath};target=any;"; + + private static long ExpectedSum(int rows) => (long)rows * (rows + 1) / 2; + + private static int PickChunk(ulong seed) + { + unchecked + { + var z = seed + 0x9E37_79B9_7F4A_7C15UL; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9UL; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EBUL; + z ^= z >> 31; + return 1 + (int)(z % 500); + } + } + + private sealed class SummingHandler : QwpColumnBatchHandler + { + public int RowCount { get; private set; } + public long IdSum { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) + { + if (batch.ColumnCount == 0 || batch.GetColumnWireType(0) != QwpTypeCode.Long) + { + throw new InvalidOperationException("expected a single LONG id column"); + } + + for (var r = 0; r < batch.RowCount; r++) + { + IdSum += batch.GetLongValue(0, r); + } + RowCount += batch.RowCount; + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpEgressFrameBuilder.cs b/src/net-questdb-client-tests/Qwp/Query/QwpEgressFrameBuilder.cs new file mode 100644 index 00000000..54dc9e65 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpEgressFrameBuilder.cs @@ -0,0 +1,473 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using QuestDB.Enums; +using QuestDB.Qwp; + +namespace net_questdb_client_tests.Qwp.Query; + +/// Test-side encoder that produces server-shaped egress frames. +internal static class QwpEgressFrameBuilder +{ + public static byte[] BuildResultBatch( + long requestId, + long batchSeq, + ResultSchema schema, + ResultBatchData data, + DeltaSymbolDict? symbolDict = null, + bool gorillaEnabled = true) + { + var payload = new MemoryStream(); + payload.WriteByte(QwpConstants.MsgKindResultBatch); + WriteI64Le(payload, requestId); + WriteVarint(payload, (ulong)batchSeq); + + var flags = QwpConstants.FlagDeltaSymbolDict; + if (gorillaEnabled) flags |= QwpConstants.FlagGorilla; + + if (symbolDict is not null) + { + WriteVarint(payload, (ulong)symbolDict.DeltaStart); + WriteVarint(payload, (ulong)symbolDict.Entries.Count); + foreach (var e in symbolDict.Entries) + { + var bytes = Encoding.UTF8.GetBytes(e); + WriteVarint(payload, (ulong)bytes.Length); + payload.Write(bytes, 0, bytes.Length); + } + } + else + { + WriteVarint(payload, 0); + WriteVarint(payload, 0); + } + + WriteTableBlock(payload, schema, data, gorillaEnabled, batchSeq); + + return WrapFrame(flags, tableCount: 1, payload.ToArray()); + } + + public static byte[] BuildResultEnd(long requestId, long finalSeq, long totalRows) + { + var payload = new MemoryStream(); + payload.WriteByte(QwpConstants.MsgKindResultEnd); + WriteI64Le(payload, requestId); + WriteVarint(payload, (ulong)finalSeq); + WriteVarint(payload, (ulong)totalRows); + return WrapFrame(flags: 0, tableCount: 0, payload.ToArray()); + } + + public static byte[] BuildQueryError(long requestId, byte status, string message) + { + var msgBytes = Encoding.UTF8.GetBytes(message); + var payload = new MemoryStream(); + payload.WriteByte(QwpConstants.MsgKindQueryError); + WriteI64Le(payload, requestId); + payload.WriteByte(status); + var lenBuf = new byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(lenBuf, (ushort)msgBytes.Length); + payload.Write(lenBuf, 0, 2); + payload.Write(msgBytes, 0, msgBytes.Length); + return WrapFrame(flags: 0, tableCount: 0, payload.ToArray()); + } + + public static byte[] BuildExecDone(long requestId, byte opType, long rowsAffected) + { + var payload = new MemoryStream(); + payload.WriteByte(QwpConstants.MsgKindExecDone); + WriteI64Le(payload, requestId); + payload.WriteByte(opType); + WriteVarint(payload, (ulong)rowsAffected); + return WrapFrame(flags: 0, tableCount: 0, payload.ToArray()); + } + + public static byte[] BuildCacheReset(byte resetMask) + { + var payload = new byte[] + { + QwpConstants.MsgKindCacheReset, resetMask + }; + return WrapFrame(flags: 0, tableCount: 0, payload); + } + +#if NET7_0_OR_GREATER + public static byte[] CompressResultBatch(byte[] uncompressedFrame) + { + var existingPayloadLen = (int)BinaryPrimitives.ReadUInt32LittleEndian( + uncompressedFrame.AsSpan(QwpConstants.OffsetPayloadLength, 4)); + var existingFlags = uncompressedFrame[QwpConstants.OffsetFlags]; + var payload = uncompressedFrame.AsSpan(QwpConstants.HeaderSize, existingPayloadLen); + + QwpVarint.Read(payload.Slice(9), out var seqVarintLen); + var preludeLen = 1 + 8 + seqVarintLen; + + using var compressor = new ZstdSharp.Compressor(level: 3); + var rawBody = payload.Slice(preludeLen).ToArray(); + var compressedBody = new byte[ZstdSharp.Compressor.GetCompressBound(rawBody.Length)]; + var written = compressor.Wrap(rawBody, compressedBody); + + var newPayloadLen = preludeLen + written; + var newPayload = new byte[newPayloadLen]; + payload.Slice(0, preludeLen).CopyTo(newPayload); + compressedBody.AsSpan(0, written).CopyTo(newPayload.AsSpan(preludeLen)); + + var tableCount = BinaryPrimitives.ReadUInt16LittleEndian( + uncompressedFrame.AsSpan(QwpConstants.OffsetTableCount, 2)); + return WrapFrame((byte)(existingFlags | QwpConstants.FlagZstd), tableCount, newPayload); + } +#endif + + public static byte[] BuildServerInfo(QwpRole role, ulong epoch, uint capabilities, long serverWallNs, + string clusterId, string nodeId, string? zoneId = null) + { + var clusterBytes = Encoding.UTF8.GetBytes(clusterId); + var nodeBytes = Encoding.UTF8.GetBytes(nodeId); + var payload = new MemoryStream(); + payload.WriteByte(QwpConstants.MsgKindServerInfo); + payload.WriteByte((byte)role); + WriteU64Le(payload, epoch); + WriteU32Le(payload, capabilities); + WriteI64Le(payload, serverWallNs); + WriteU16Le(payload, (ushort)clusterBytes.Length); + payload.Write(clusterBytes, 0, clusterBytes.Length); + WriteU16Le(payload, (ushort)nodeBytes.Length); + payload.Write(nodeBytes, 0, nodeBytes.Length); + if ((capabilities & QwpConstants.CapZone) != 0) + { + var zoneBytes = Encoding.UTF8.GetBytes(zoneId ?? string.Empty); + WriteU16Le(payload, (ushort)zoneBytes.Length); + payload.Write(zoneBytes, 0, zoneBytes.Length); + } + + return WrapFrame(flags: 0, tableCount: 0, payload.ToArray()); + } + + private static byte[] WrapFrame(byte flags, ushort tableCount, byte[] payload) + { + var frame = new byte[QwpConstants.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetMagic, 4), QwpConstants.Magic); + frame[QwpConstants.OffsetVersion] = QwpConstants.SupportedVersion; + frame[QwpConstants.OffsetFlags] = flags; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2), tableCount); + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetPayloadLength, 4), (uint)payload.Length); + Buffer.BlockCopy(payload, 0, frame, QwpConstants.HeaderSize, payload.Length); + return frame; + } + + private static void WriteTableBlock( + MemoryStream payload, ResultSchema schema, ResultBatchData data, bool gorillaEnabled, long batchSeq) + { + WriteVarint(payload, 0); // empty table name + WriteVarint(payload, (ulong)data.RowCount); + + // batch_seq == 0 carries the inline schema; continuation batches carry only rows. + if (batchSeq == 0) + { + WriteVarint(payload, (ulong)schema.Columns.Count); + foreach (var c in schema.Columns) + { + var nameBytes = Encoding.UTF8.GetBytes(c.Name); + WriteVarint(payload, (ulong)nameBytes.Length); + payload.Write(nameBytes, 0, nameBytes.Length); + payload.WriteByte((byte)c.TypeCode); + } + } + + for (var i = 0; i < schema.Columns.Count; i++) + { + data.Columns[i].WriteTo(payload, schema.Columns[i].TypeCode, data.RowCount, gorillaEnabled); + } + } + + private static void WriteVarint(MemoryStream s, ulong value) + { + Span buf = stackalloc byte[10]; + var n = QwpVarint.Write(buf, value); + s.Write(buf.Slice(0, n)); + } + + private static void WriteI64Le(MemoryStream s, long value) + { + Span buf = stackalloc byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(buf, value); + s.Write(buf); + } + + private static void WriteU64Le(MemoryStream s, ulong value) + { + Span buf = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(buf, value); + s.Write(buf); + } + + private static void WriteU32Le(MemoryStream s, uint value) + { + Span buf = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(buf, value); + s.Write(buf); + } + + private static void WriteU16Le(MemoryStream s, ushort value) + { + Span buf = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(buf, value); + s.Write(buf); + } +} + +internal sealed class ResultSchema +{ + public List Columns { get; init; } = new(); +} + +internal sealed record SchemaColumn(string Name, QwpTypeCode TypeCode); + +internal sealed class ResultBatchData +{ + public int RowCount { get; init; } + public List Columns { get; init; } = new(); +} + +internal sealed class DeltaSymbolDict +{ + public int DeltaStart { get; init; } + public List Entries { get; init; } = new(); +} + +internal abstract class TestColumnData +{ + public byte[]? NullBitmap { get; init; } + + public void WriteTo(MemoryStream s, QwpTypeCode wireType, int rowCount, bool gorillaEnabled) + { + if (NullBitmap is null) + { + s.WriteByte(0); + } + else + { + s.WriteByte(1); + var expected = (rowCount + 7) >> 3; + if (NullBitmap.Length < expected) + { + throw new InvalidOperationException("null bitmap too short"); + } + + s.Write(NullBitmap, 0, expected); + } + + WriteValueRegion(s, wireType, gorillaEnabled); + } + + protected abstract void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled); +} + +internal sealed class FixedColumnData : TestColumnData +{ + public byte[] DenseBytes { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + s.Write(DenseBytes, 0, DenseBytes.Length); + } +} + +internal sealed class TimestampColumnData : TestColumnData +{ + public long[] DenseValues { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + if (!gorillaEnabled) + { + foreach (var v in DenseValues) + { + Span buf = stackalloc byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(buf, v); + s.Write(buf); + } + + return; + } + + var dest = new byte[QwpGorilla.MaxEncodedSize(DenseValues.Length)]; + var n = QwpGorilla.Encode(dest, DenseValues); + s.Write(dest, 0, n); + } +} + +internal sealed class VarcharColumnData : TestColumnData +{ + public string[] DenseValues { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + var bytes = new byte[DenseValues.Length][]; + var totalLen = 0; + for (var i = 0; i < DenseValues.Length; i++) + { + bytes[i] = Encoding.UTF8.GetBytes(DenseValues[i]); + totalLen += bytes[i].Length; + } + + var offsets = new int[DenseValues.Length + 1]; + for (var i = 0; i < DenseValues.Length; i++) + { + offsets[i + 1] = offsets[i] + bytes[i].Length; + } + + Span intBuf = stackalloc byte[4]; + for (var i = 0; i <= DenseValues.Length; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(intBuf, offsets[i]); + s.Write(intBuf); + } + + for (var i = 0; i < DenseValues.Length; i++) + { + s.Write(bytes[i], 0, bytes[i].Length); + } + } +} + +internal sealed class BinaryColumnData : TestColumnData +{ + public byte[][] DenseValues { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + var offsets = new int[DenseValues.Length + 1]; + for (var i = 0; i < DenseValues.Length; i++) + { + offsets[i + 1] = offsets[i] + DenseValues[i].Length; + } + + Span intBuf = stackalloc byte[4]; + for (var i = 0; i <= DenseValues.Length; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(intBuf, offsets[i]); + s.Write(intBuf); + } + + for (var i = 0; i < DenseValues.Length; i++) + { + s.Write(DenseValues[i], 0, DenseValues[i].Length); + } + } +} + +internal sealed class SymbolColumnData : TestColumnData +{ + public int[] DenseDictIds { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + Span buf = stackalloc byte[10]; + foreach (var id in DenseDictIds) + { + var n = QwpVarint.Write(buf, (ulong)id); + s.Write(buf.Slice(0, n)); + } + } +} + +internal sealed class DecimalColumnData : TestColumnData +{ + public byte Scale { get; init; } + public byte[] DenseBytes { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + s.WriteByte(Scale); + s.Write(DenseBytes, 0, DenseBytes.Length); + } +} + +internal sealed class GeohashColumnData : TestColumnData +{ + public int PrecisionBits { get; init; } + public byte[] DenseBytes { get; init; } + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + Span buf = stackalloc byte[10]; + var n = QwpVarint.Write(buf, (ulong)PrecisionBits); + s.Write(buf.Slice(0, n)); + s.Write(DenseBytes, 0, DenseBytes.Length); + } +} + +internal sealed class DoubleArrayColumnData : TestColumnData +{ + public (int[] Shape, double[] Values)[] DenseArrays { get; init; } = Array.Empty<(int[], double[])>(); + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + Span dim = stackalloc byte[4]; + Span v = stackalloc byte[8]; + foreach (var (shape, values) in DenseArrays) + { + s.WriteByte((byte)shape.Length); + foreach (var d in shape) + { + BinaryPrimitives.WriteInt32LittleEndian(dim, d); + s.Write(dim); + } + + foreach (var x in values) + { + BinaryPrimitives.WriteInt64LittleEndian(v, BitConverter.DoubleToInt64Bits(x)); + s.Write(v); + } + } + } +} + +internal sealed class LongArrayColumnData : TestColumnData +{ + public (int[] Shape, long[] Values)[] DenseArrays { get; init; } = Array.Empty<(int[], long[])>(); + + protected override void WriteValueRegion(MemoryStream s, QwpTypeCode wireType, bool gorillaEnabled) + { + Span dim = stackalloc byte[4]; + Span v = stackalloc byte[8]; + foreach (var (shape, values) in DenseArrays) + { + s.WriteByte((byte)shape.Length); + foreach (var d in shape) + { + BinaryPrimitives.WriteInt32LittleEndian(dim, d); + s.Write(dim); + } + + foreach (var x in values) + { + BinaryPrimitives.WriteInt64LittleEndian(v, x); + s.Write(v); + } + } + } +} \ No newline at end of file diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientEndToEndTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientEndToEndTests.cs new file mode 100644 index 00000000..972a94d9 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientEndToEndTests.cs @@ -0,0 +1,1794 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Utils; +using dummy_http_server; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QwpQueryClientEndToEndTests +{ + [Test] + public async Task SelectOne_Roundtrips() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongLe(42L) } }, + }; + + var batchFrame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var endFrame = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batchFrame, endFrame }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "target=any;")); + var handler = new RecordingHandler(); + client.Execute("SELECT 42", handler); + + Assert.That(handler.Batches.Count, Is.EqualTo(1)); + Assert.That(handler.Batches[0].LongValues, Is.EqualTo(new[] { 42L })); + Assert.That(handler.Ended, Is.True); + Assert.That(handler.TotalRows, Is.EqualTo(1L)); + } + + [Test] + public async Task ServerErrorFrame_TerminatesViaOnError() + { + var errFrame = QwpEgressFrameBuilder.BuildQueryError(1L, QwpConstants.StatusParseError, "bad SQL"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { errFrame }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var handler = new RecordingHandler(); + client.Execute("SELECT bogus", handler); + + Assert.That(handler.LastErrorStatus, Is.EqualTo(QwpConstants.StatusParseError)); + Assert.That(handler.LastErrorMessage, Is.EqualTo("bad SQL")); + Assert.That(handler.Ended, Is.False); + } + + [Test] + public async Task ExecDoneFrame_DispatchesOnExecDone() + { + var doneFrame = QwpEgressFrameBuilder.BuildExecDone(1L, opType: 7, rowsAffected: 99L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { doneFrame }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var handler = new RecordingHandler(); + client.Execute("INSERT INTO t VALUES(1)", handler); + + Assert.That(handler.LastExecOpType, Is.EqualTo((byte)7)); + Assert.That(handler.LastExecRowsAffected, Is.EqualTo(99L)); + } + + [Test] + public async Task V2ServerInfo_IsConsumedAtConnect_AndExposedViaServerInfo() + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpRole.Primary, + epoch: 7UL, + capabilities: 0, + serverWallNs: 1_700_000_000_000_000_000L, + clusterId: "qdb-prod", + nodeId: "node-1"); + + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "target=primary;")); + + Assert.That(client.ServerInfo, Is.Not.Null); + Assert.That(client.ServerInfo!.Role, Is.EqualTo(QwpRole.Primary)); + Assert.That(client.ServerInfo.RoleName, Is.EqualTo("PRIMARY")); + Assert.That(client.ServerInfo.Epoch, Is.EqualTo(7UL)); + Assert.That(client.ServerInfo.ClusterId, Is.EqualTo("qdb-prod")); + Assert.That(client.ServerInfo.NodeId, Is.EqualTo("node-1")); + + var handler = new RecordingHandler(); + client.Execute("SELECT 1", handler); + Assert.That(handler.Ended, Is.True); + } + + [Test] + public async Task V2ServerInfo_TrailingBytes_FromUnknownCapBit_AreIgnored() + { + var baseFrame = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpRole.Primary, + epoch: 1UL, + capabilities: 0, + serverWallNs: 0L, + clusterId: "c", + nodeId: "n"); + + var extra = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + var extended = new byte[baseFrame.Length + extra.Length]; + Buffer.BlockCopy(baseFrame, 0, extended, 0, baseFrame.Length); + Buffer.BlockCopy(extra, 0, extended, baseFrame.Length, extra.Length); + var existingLen = BinaryPrimitives.ReadUInt32LittleEndian( + extended.AsSpan(QwpConstants.OffsetPayloadLength, 4)); + BinaryPrimitives.WriteUInt32LittleEndian( + extended.AsSpan(QwpConstants.OffsetPayloadLength, 4), + existingLen + (uint)extra.Length); + + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = extended, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "target=primary;")); + Assert.That(client.ServerInfo, Is.Not.Null); + Assert.That(client.ServerInfo!.Role, Is.EqualTo(QwpRole.Primary)); + Assert.That(client.ServerInfo.NodeId, Is.EqualTo("n")); + } + + [Test] + public async Task V2ServerInfo_WithCapZone_ExposesZoneId() + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpRole.Primary, + epoch: 1UL, + capabilities: QwpConstants.CapZone, + serverWallNs: 0L, + clusterId: "c", + nodeId: "n", + zoneId: "eu-west-1a"); + + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "target=any;zone=eu-west-1a;")); + Assert.That(client.ServerInfo, Is.Not.Null); + Assert.That(client.ServerInfo!.Capabilities & QwpConstants.CapZone, Is.Not.EqualTo(0u)); + Assert.That(client.ServerInfo.ZoneId, Is.EqualTo("eu-west-1a")); + } + + [Test] + public async Task ZonePreference_TargetPrimary_IgnoresZone() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("n", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + var infoWest = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, 1UL, QwpConstants.CapZone, 0L, "c", "node-west", "us-west-2"); + var infoEast = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, 2UL, QwpConstants.CapZone, 0L, "c", "node-east", "us-east-1"); + + await using var west = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, NegotiatedVersion = "1", + InitialServerFrame = infoWest, FrameHandlerMulti = _ => new[] { batch, end }, + }); + await using var east = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, NegotiatedVersion = "1", + InitialServerFrame = infoEast, FrameHandlerMulti = _ => new[] { batch, end }, + }); + await west.StartAsync(); + await east.StartAsync(); + + var conn = $"ws::addr={west.Uri.Authority},{east.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;zone=us-east-1;"; + using var client = QueryClient.New(conn); + Assert.That(client.ServerInfo!.NodeId, Is.EqualTo("node-west")); + } + + [Test] + public async Task RoleReject_WithZoneHeader_RecordsZoneOnTracker() + { + await using var east = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RoleReplicaName, + RejectUpgradeZoneHeader = "us-east-1", + }); + await using var west = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RoleReplicaName, + RejectUpgradeZoneHeader = "us-west-2", + }); + await east.StartAsync(); + await west.StartAsync(); + + var conn = $"ws::addr={east.Uri.Authority},{west.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;zone=us-east-1;failover=on;failover_max_attempts=2;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"; + var ex = Assert.Throws(() => QueryClient.New(conn)); + Assert.That(ex!.LastObserved, Is.Not.Null); + Assert.That(ex.LastObserved!.ZoneId, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task StandaloneServer_TargetReplica_RejectedWithRoleMismatch() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + }); + await server.StartAsync(); + + var ex = Assert.Throws(() => + QueryClient.New(BuildConnString(server, "target=replica;"))); + Assert.That(ex!.Target, Is.EqualTo(TargetType.replica)); + Assert.That(ex.LastObserved, Is.Not.Null); + Assert.That(ex.LastObserved!.Role, Is.EqualTo(QwpRole.Standalone)); + } + + [Test] + public async Task TargetAny_AcceptsAnyRole() + { + var batch = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, + new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "target=any;")); + Assert.That(client.ServerInfo, Is.Not.Null); + client.Execute("SELECT 1", new RecordingHandler()); + } + + [Test] + public async Task V2ServerInfo_RoleMismatch_ThrowsAndCarriesLastObserved() + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpRole.Replica, epoch: 1UL, capabilities: 0, serverWallNs: 0L, + clusterId: "c", nodeId: "n"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + }); + await server.StartAsync(); + + var ex = Assert.Throws(() => + QueryClient.New(BuildConnString(server, "target=primary;"))); + Assert.That(ex!.Target, Is.EqualTo(TargetType.primary)); + Assert.That(ex.LastObserved, Is.Not.Null); + Assert.That(ex.LastObserved!.Role, Is.EqualTo(QwpRole.Replica)); + } + + [Test] + public async Task BindParameters_AreEmittedInQueryRequestFrame() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(7L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + client.Execute("SELECT $1, $2", b => + { + b.SetLong(0, 100L); + b.SetVarchar(1, "abc"); + }, new RecordingHandler()); + + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1)); + var requestFrame = server.ReceivedFrames.First(); + Assert.That(requestFrame[0], Is.EqualTo(QwpConstants.MsgKindQueryRequest)); + var requestId = BinaryPrimitives.ReadInt64LittleEndian(requestFrame.AsSpan(1, 8)); + Assert.That(requestId, Is.GreaterThan(0)); + } + + [Test] + public async Task UpgradeHeaders_CarryClientIdAndAcceptEncodingAndMaxVersion() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 0L) }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "client_id=tester/9.9;compression=zstd;compression_level=5;")); + client.Execute("SELECT 1", new RecordingHandler()); + + Assert.That(server.LastUpgradeHeaders, Is.Not.Null); + Assert.That(server.LastUpgradeHeaders![QwpConstants.HeaderClientId], Is.EqualTo("tester/9.9")); + Assert.That(server.LastUpgradeHeaders[QwpConstants.HeaderAcceptEncoding], Is.EqualTo("zstd;level=5,raw")); + Assert.That(server.LastUpgradeHeaders[QwpConstants.HeaderMaxVersion], + Is.EqualTo(QwpConstants.SupportedVersion.ToString())); + } + + [Test] + public async Task UpgradeHeaders_CompressionAuto_AdvertisesZstdWithRawFallback() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 0L) }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=auto;compression_level=3;")); + client.Execute("SELECT 1", new RecordingHandler()); + + Assert.That(server.LastUpgradeHeaders![QwpConstants.HeaderAcceptEncoding], Is.EqualTo("zstd;level=3,raw")); + } + + [Test] + public async Task UpgradeHeaders_CarryAuthorization_BasicAuth() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 0L) }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "username=alice;password=p4ss;")); + client.Execute("SELECT 1", new RecordingHandler()); + + var expected = "Basic " + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("alice:p4ss")); + Assert.That(server.LastUpgradeHeaders!["Authorization"], Is.EqualTo(expected)); + } + + [Test] + public async Task UpgradeHeaders_CarryAuthorization_BearerToken() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 0L) }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "token=abc.def;")); + client.Execute("SELECT 1", new RecordingHandler()); + + Assert.That(server.LastUpgradeHeaders!["Authorization"], Is.EqualTo("Bearer abc.def")); + } + + [Test] + public async Task UpgradeHeaders_CarryMaxBatchRowsWhenSet() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 0L) }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "max_batch_rows=512;")); + client.Execute("SELECT 1", new RecordingHandler()); + + Assert.That(server.LastUpgradeHeaders![QwpConstants.HeaderMaxBatchRows], Is.EqualTo("512")); + } + + [Test] + public async Task Cancel_FromMultipleThreads_DoesNotRaceTransportSend() + { + // Regression: Cancel() must serialise with the I/O-loop sender or ClientWebSocket throws. + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + + var cancellations = Enumerable.Range(0, 16) + .Select(_ => Task.Run(() => + { + for (var i = 0; i < 8; i++) client.Cancel(); + })) + .ToArray(); + + Assert.DoesNotThrow(() => client.Execute("SELECT 1", new RecordingHandler())); + await Task.WhenAll(cancellations); + } + + [Test] + public async Task NewAsync_ConnectsAndReturnsLiveClient() + { + var batch = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, + new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(7L) } } }); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + await using var client = await QueryClient.NewAsync(BuildConnString(server)); + var handler = new RecordingHandler(); + await client.ExecuteAsync("SELECT 7", handler); + + Assert.That(handler.Ended, Is.True); + Assert.That(handler.Batches.Count, Is.EqualTo(1)); + } + + [Test] + public async Task HandlerException_AbortsCurrentQuery_KeepsConnectionUsable() + { + // Regression: handler throw aborts the query but the connection stays usable. + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var batch1 = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }); + var batch2 = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 1L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(2L) } } }); + var end1 = QwpEgressFrameBuilder.BuildResultEnd(1L, 1L, 2L); + + var batch3 = QwpEgressFrameBuilder.BuildResultBatch( + 2L, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(99L) } } }); + var end2 = QwpEgressFrameBuilder.BuildResultEnd(2L, 0L, 1L); + + var queryCount = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = frame => + { + if (frame[0] == QwpConstants.MsgKindQueryRequest) + { + queryCount++; + return queryCount == 1 ? new[] { batch1, batch2, end1 } : new[] { batch3, end2 }; + } + return Array.Empty(); + }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + + var thrown = new InvalidOperationException("handler boom"); + var throwing = new ThrowingHandler(thrown); + var caught = Assert.Throws( + () => client.Execute("SELECT 1", throwing)); + Assert.That(caught, Is.SameAs(thrown)); + Assert.That(throwing.BatchCount, Is.EqualTo(1)); + + var ok = new RecordingHandler(); + client.Execute("SELECT 99", ok); + Assert.That(ok.Ended, Is.True); + Assert.That(ok.Batches.Count, Is.EqualTo(1)); + Assert.That(ok.Batches[0].LongValues, Is.EqualTo(new[] { 99L })); + } + + private sealed class ThrowingHandler : QwpColumnBatchHandler + { + private readonly Exception _toThrow; + public int BatchCount; + + public ThrowingHandler(Exception toThrow) => _toThrow = toThrow; + + public override void OnBatch(QwpColumnBatch batch) + { + BatchCount++; + throw _toThrow; + } + } + + [Test] + public async Task UpgradeRejectedWith401_SurfaceAuthError() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.Unauthorized, + }); + await server.StartAsync(); + + var ex = Assert.Catch(() => QueryClient.New(BuildConnString(server))); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.AuthError)); + } + + [Test] + public async Task FirstRequestId_IsOne() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(0L) } } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + client.Execute("SELECT 0", new RecordingHandler()); + + var receivedList = server.ReceivedFrames.ToList(); + Assert.That(receivedList.Count, Is.EqualTo(1)); + var requestId = BinaryPrimitives.ReadInt64LittleEndian(receivedList[0].AsSpan(1, 8)); + Assert.That(requestId, Is.EqualTo(1L)); + } + + [Test] + public async Task UserCancellation_AbortsSocketAndMarksClientTerminal() + { + // ct-cancellation aborts the underlying ClientWebSocket so CANCEL is not deliverable; + // the client goes terminal and the user must create a fresh one. Callers that want a + // graceful server-side cancel should use Cancel() (see MidQueryCancel_ReturnsQueryErrorCancelled). + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = frame => + { + if (frame[0] != QwpConstants.MsgKindQueryRequest) return null; + var rid = BinaryPrimitives.ReadInt64LittleEndian(frame.AsSpan(1, 8)); + return new[] + { + QwpEgressFrameBuilder.BuildResultBatch(rid, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }), + }; + }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + Assert.CatchAsync(async () => + await client.ExecuteAsync("SELECT 1", new RecordingHandler(), cts.Token)); + + var ex = Assert.Throws(() => + client.Execute("SELECT 2", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + Assert.That(ex.Message, Does.Contain("terminal")); + } + + [Test] + public async Task SqlExceedingMaxLength_Throws() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => Array.Empty(), + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var hugeSql = new string('x', QwpConstants.MaxSqlLengthBytes + 1); + var ex = Assert.Throws(() => client.Execute(hugeSql, new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.InvalidApiCall)); + } + + [Test] + public async Task CacheReset_ClearsSymbolDictAndAllowsNextDeltaToStartAtZero() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("s", QwpTypeCode.Symbol) } }; + var dict1 = new DeltaSymbolDict { DeltaStart = 0, Entries = { "alpha", "beta" } }; + var data1 = new ResultBatchData + { + RowCount = 1, + Columns = { new SymbolColumnData { DenseDictIds = new[] { 0 } } }, + }; + var batch1 = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data1, dict1); + var end1 = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + var resetFrame = QwpEgressFrameBuilder.BuildCacheReset(QwpConstants.ResetMaskDict); + + // Second query reuses schema id 1 but starts dict delta at 0 — only valid after CACHE_RESET. + var dict2 = new DeltaSymbolDict { DeltaStart = 0, Entries = { "gamma" } }; + var data2 = new ResultBatchData + { + RowCount = 1, + Columns = { new SymbolColumnData { DenseDictIds = new[] { 0 } } }, + }; + var batch2 = QwpEgressFrameBuilder.BuildResultBatch(2L, 0L, schema, data2, dict2); + var end2 = QwpEgressFrameBuilder.BuildResultEnd(2L, 0L, 1L); + + var queryCount = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = frame => + { + if (frame[0] != QwpConstants.MsgKindQueryRequest) return Array.Empty(); + queryCount++; + return queryCount == 1 + ? new[] { batch1, end1 } + : new[] { resetFrame, batch2, end2 }; + }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var h1 = new RecordingHandler(); + client.Execute("SELECT 1", h1); + Assert.That(h1.Ended, Is.True); + + var h2 = new RecordingHandler(); + client.Execute("SELECT 2", h2); + Assert.That(h2.Ended, Is.True); + Assert.That(h2.Batches.Count, Is.EqualTo(1)); + } + + [Test] + public async Task CreditFlow_WhenInitialCreditSet_SendsCreditFrameAfterEachBatch() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var batch1 = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }); + var batch2 = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 1L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(2L) } } }); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 1L, 2L); + + // Only respond to QUERY_REQUEST; do nothing for CREDIT/CANCEL frames so they're just captured. + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = frame => + frame.Length > 0 && frame[0] == QwpConstants.MsgKindQueryRequest + ? new[] { batch1, batch2, end } + : Array.Empty(), + }); + await server.StartAsync(); + + // initial_credit small enough that each batch crosses the half-threshold and triggers a CREDIT. + var options = new QueryOptions(BuildConnString(server)) { initial_credit = 50 }; + using var client = QueryClient.New(options); + client.Execute("SELECT 1", new RecordingHandler()); + + var deadline = DateTime.UtcNow.AddSeconds(2); + List creditFrames; + do + { + creditFrames = server.ReceivedFrames + .Where(f => f.Length > 0 && f[0] == QwpConstants.MsgKindCredit) + .ToList(); + if (creditFrames.Count >= 2) break; + await Task.Delay(20); + } while (DateTime.UtcNow < deadline); + + Assert.That(creditFrames.Count, Is.EqualTo(2)); + var rid = BinaryPrimitives.ReadInt64LittleEndian(creditFrames[0].AsSpan(1, 8)); + Assert.That(rid, Is.EqualTo(1L)); + } + + [Test] + public async Task SliceFrame_BadMagic_ThrowsProtocolViolation() + { + var bogus = new byte[QwpConstants.HeaderSize + 1]; + bogus[0] = (byte)'X'; bogus[1] = bogus[2] = bogus[3] = 0; + bogus[QwpConstants.OffsetVersion] = 1; + BinaryPrimitives.WriteUInt32LittleEndian(bogus.AsSpan(QwpConstants.OffsetPayloadLength, 4), 1); + bogus[QwpConstants.HeaderSize] = QwpConstants.MsgKindResultEnd; + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { bogus }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + StringAssert.Contains("magic", ex.Message); + } + + [Test] + public async Task SliceFrame_PayloadLengthMismatch_ThrowsProtocolViolation() + { + var hdr = new byte[QwpConstants.HeaderSize + 5]; + BinaryPrimitives.WriteUInt32LittleEndian(hdr.AsSpan(0, 4), QwpConstants.Magic); + hdr[QwpConstants.OffsetVersion] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(hdr.AsSpan(QwpConstants.OffsetTableCount, 2), 0); + // header announces 99 bytes but only 5 follow. + BinaryPrimitives.WriteUInt32LittleEndian(hdr.AsSpan(QwpConstants.OffsetPayloadLength, 4), 99); + hdr[QwpConstants.HeaderSize] = QwpConstants.MsgKindResultEnd; + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { hdr }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + } + + [Test] + public async Task UnknownMsgKind_ThrowsProtocolViolation() + { + var hdr = new byte[QwpConstants.HeaderSize + 1]; + BinaryPrimitives.WriteUInt32LittleEndian(hdr.AsSpan(0, 4), QwpConstants.Magic); + hdr[QwpConstants.OffsetVersion] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(hdr.AsSpan(QwpConstants.OffsetTableCount, 2), 0); + BinaryPrimitives.WriteUInt32LittleEndian(hdr.AsSpan(QwpConstants.OffsetPayloadLength, 4), 1); + hdr[QwpConstants.HeaderSize] = 0xFE; + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { hdr }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + StringAssert.Contains("unknown egress frame", ex.Message); + } + + [Test] + public async Task ZstdCompressedBatch_DecodesCorrectly() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("v", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 4, + Columns = { new FixedColumnData { DenseBytes = LongsLe(11L, 22L, 33L, 44L) } }, + }; + var rawBatch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var compressed = QwpEgressFrameBuilder.CompressResultBatch(rawBatch); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 4L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { compressed, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=zstd;")); + var handler = new RecordingHandler(); + client.Execute("SELECT v FROM t", handler); + + Assert.That(handler.Batches.Count, Is.EqualTo(1)); + Assert.That(handler.Batches[0].LongValues, Is.EqualTo(new[] { 11L, 22L, 33L, 44L })); + Assert.That(handler.Ended, Is.True); + } + + [Test] + public async Task Failover_TransportFailsOnFirstEndpoint_RetriesNextAndFiresOnFailoverReset() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(7L) } } }; + + var serverAInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + var serverBInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 2, capabilities: 0, serverWallNs: 0, "c", "node-b"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverAInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await serverA.StartAsync(); + + // request_id is preserved across failover attempts; the second attempt against serverB + // carries the same rid=1 that was used against serverA. + var batchB = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var endB = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverBInfo, + FrameHandlerMulti = _ => new[] { batchB, endB }, + }); + await serverB.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"; + using var client = QueryClient.New(conn); + var handler = new RecordingHandler(); + client.Execute("SELECT 7", handler); + + Assert.That(handler.FailoverResets.Count, Is.GreaterThanOrEqualTo(1)); + Assert.That(handler.FailoverResets[^1]?.NodeId, Is.EqualTo("node-b")); + Assert.That(handler.Batches.Count, Is.EqualTo(1)); + Assert.That(handler.Batches[0].LongValues, Is.EqualTo(new[] { 7L })); + Assert.That(handler.Ended, Is.True); + } + + [Test] + public async Task MidQueryCancel_ReturnsQueryErrorCancelled() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var batch1 = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }); + var cancelledErr = QwpEgressFrameBuilder.BuildQueryError( + 1L, QwpConstants.StatusCancelled, "cancelled by client"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandler = frame => + { + var msgKind = frame[0]; + if (msgKind == QwpConstants.MsgKindQueryRequest) return batch1; + if (msgKind == QwpConstants.MsgKindCancel) return cancelledErr; + return null; + }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var handler = new RecordingHandler(); + handler.OnBatchHook = _ => client.Cancel(); + client.Execute("SELECT 1", handler); + + Assert.That(handler.Batches.Count, Is.EqualTo(1)); + Assert.That(handler.LastErrorStatus, Is.EqualTo(QwpConstants.StatusCancelled)); + Assert.That(handler.LastErrorMessage, Is.EqualTo("cancelled by client")); + } + + // CacheReset_SchemaBitClearsRegistry_NextReferenceModeBatchFails — removed: + // the schema-reference mechanism is gone, so RESET_MASK_SCHEMAS no longer exists. + // Per-query schema invalidation is exercised by + // QwpResultBatchDecoderTests.ResetQuerySchema_InvalidatesPriorBatch0Schema. + + [Test] + public async Task ExecuteReentrancy_ThrowsInvalidApiCall() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var batch = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMultiAsync = async _ => + { + gate.TrySetResult(true); + await Task.Delay(200); + return new[] { batch, end }; + }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var first = client.ExecuteAsync("SELECT 1", new RecordingHandler()); + await gate.Task; + + var ex = Assert.Throws(() => client.Execute("SELECT 2", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.InvalidApiCall)); + await first; + } + + [Test] + public async Task Failover_AllEndpointsRejectUpgrade_SurfacesSocketError() + { + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.BadGateway, + }); + await serverA.StartAsync(); + + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.ServiceUnavailable, + }); + await serverB.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=any;failover=on;failover_max_attempts=2;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"; + + var ex = Assert.Throws(() => QueryClient.New(conn)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + StringAssert.Contains("connect failed", ex.Message); + } + + [Test] + public async Task Failover_AuthErrorOnReconnect_PropagatesWithoutFurtherRetry() + { + // Server A accepts connect+SERVER_INFO then drops mid-query; server B rejects with 401. + // The reconnect catch block (`IngressError ConfigError or AuthError`) must NOT swallow. + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await serverA.StartAsync(); + + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.Unauthorized, + }); + await serverB.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"; + using var client = QueryClient.New(conn); + + var ex = Assert.Catch(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.AuthError)); + } + + [Test] + public async Task Failover_RotatesAcross3Endpoints_ThirdSucceeds() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(99L) } } }; + var infoC = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 3, capabilities: 0, serverWallNs: 0, "c", "node-c"); + var batchC = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var endC = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.BadGateway, + }); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = HttpStatusCode.ServiceUnavailable, + }); + await using var serverC = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = infoC, + FrameHandlerMulti = _ => new[] { batchC, endC }, + }); + await serverA.StartAsync(); + await serverB.StartAsync(); + await serverC.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority},{serverC.Uri.Authority};" + + $"path={QwpConstants.ReadPath};target=primary;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"; + using var client = QueryClient.New(conn); + Assert.That(client.ServerInfo!.NodeId, Is.EqualTo("node-c")); + + var handler = new RecordingHandler(); + client.Execute("SELECT 99", handler); + Assert.That(handler.Batches[0].LongValues, Is.EqualTo(new[] { 99L })); + Assert.That(handler.Ended, Is.True); + } + + [Test] + public async Task Failover_ExhaustsMaxAttempts_BothServersFailing() + { + // Both endpoints drop mid-query forever; capped at max_attempts=2 so the test terminates. + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "n"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await serverA.StartAsync(); + await serverB.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;failover=on;failover_max_attempts=2;" + + "failover_backoff_initial_ms=5;failover_backoff_max_ms=10;"; + using var client = QueryClient.New(conn); + + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + } + + [Test] + public async Task Failover_AllEndpointsRoleMismatch_RaisesQwpRoleMismatchException() + { + // Two servers both report REPLICA role; target=primary can't satisfy any. + var info = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Replica, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "replica"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = info, + }); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = info, + }); + await serverA.StartAsync(); + await serverB.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;failover=on;failover_max_attempts=2;" + + "failover_backoff_initial_ms=5;failover_backoff_max_ms=10;"; + + var ex = Assert.Throws(() => QueryClient.New(conn)); + Assert.That(ex!.Target, Is.EqualTo(TargetType.primary)); + Assert.That(ex.LastObserved, Is.Not.Null); + Assert.That(ex.LastObserved!.Role, Is.EqualTo(QwpRole.Replica)); + } + + [Test] + public void AuthTimeout_BoundsConnectAttemptToBlackholeHost() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var held = new List(); + using var acceptCts = new CancellationTokenSource(); + var acceptTask = Task.Run(async () => + { + try + { + while (!acceptCts.IsCancellationRequested) + { + var c = await listener.AcceptTcpClientAsync(acceptCts.Token); + held.Add(c); + } + } + catch { } + }); + + try + { + var conn = $"ws::addr=127.0.0.1:{port};path={QwpConstants.ReadPath};" + + "auth_timeout_ms=300;failover=off;"; + var sw = Stopwatch.StartNew(); + var ex = Assert.Throws(() => QueryClient.New(conn)); + sw.Stop(); + + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + StringAssert.Contains("auth_timeout", ex.Message); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(3000), + "auth_timeout=300ms should bound connect well below OS-level TCP timeout"); + } + finally + { + acceptCts.Cancel(); + listener.Stop(); + foreach (var c in held) try { c.Close(); } catch { } + } + } + + [Test] + public async Task FailoverMaxDuration_ShortCircuitsBeforeMaxAttempts() + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = serverInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await server.StartAsync(); + + var conn = $"ws::addr={server.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;failover=on;failover_max_attempts=200;failover_max_duration_ms=300;" + + "failover_backoff_initial_ms=20;failover_backoff_max_ms=50;auth_timeout_ms=500;"; + using var client = QueryClient.New(conn); + + var sw = Stopwatch.StartNew(); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + sw.Stop(); + + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + Assert.That(sw.ElapsedMilliseconds, Is.LessThan(2000), + "200 attempts at 20-50ms backoff is multi-second; max_duration=300ms must short-circuit"); + } + + [Test] + public async Task MidStreamFailure_DemotesActiveHost_NextReconnectPicksOther() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var infoA = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + var infoB = QwpEgressFrameBuilder.BuildServerInfo( + QwpRole.Primary, epoch: 2, capabilities: 0, serverWallNs: 0, "c", "node-b"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = infoA, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await serverA.StartAsync(); + + var aConnections = 0; + var bConnections = 0; + + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + InitialServerFrame = infoB, + FrameHandlerMulti = frame => + { + if (frame[0] != QwpConstants.MsgKindQueryRequest) return null; + bConnections++; + var rid = BinaryPrimitives.ReadInt64LittleEndian(frame.AsSpan(1, 8)); + return new[] + { + QwpEgressFrameBuilder.BuildResultBatch(rid, 0L, schema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(7L) } } }), + QwpEgressFrameBuilder.BuildResultEnd(rid, 0L, 1L), + }; + }, + }); + await serverB.StartAsync(); + + var conn = $"ws::addr={serverA.Uri.Authority},{serverB.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"; + using var client = QueryClient.New(conn); + Assert.That(client.ServerInfo!.NodeId, Is.EqualTo("node-a")); + aConnections++; + + var handler = new RecordingHandler(); + client.Execute("SELECT 7", handler); + Assert.That(handler.Ended, Is.True); + Assert.That(handler.FailoverResets[^1]?.NodeId, Is.EqualTo("node-b")); + + var handler2 = new RecordingHandler(); + client.Execute("SELECT 7 again", handler2); + Assert.That(handler2.Ended, Is.True); + Assert.That(handler2.FailoverResets, Is.Empty, + "second Execute on the now-Healthy B should not reconnect — A was demoted by mid-stream failure"); + Assert.That(bConnections, Is.GreaterThanOrEqualTo(2)); + } + + [Test] + public async Task ZstdBatch_MissingPrelude_Throws() + { + // FlagZstd set but body shorter than prelude (msg_kind + req_id + batch_seq varint = ≥10 bytes). + var bogus = new byte[QwpConstants.HeaderSize + 9]; + BinaryPrimitives.WriteUInt32LittleEndian(bogus.AsSpan(0, 4), QwpConstants.Magic); + bogus[QwpConstants.OffsetVersion] = 1; + bogus[QwpConstants.OffsetFlags] = QwpConstants.FlagZstd; + BinaryPrimitives.WriteUInt16LittleEndian(bogus.AsSpan(QwpConstants.OffsetTableCount, 2), 1); + BinaryPrimitives.WriteUInt32LittleEndian(bogus.AsSpan(QwpConstants.OffsetPayloadLength, 4), 9); + bogus[QwpConstants.HeaderSize] = QwpConstants.MsgKindResultBatch; + // 8 bytes of request_id; no batch_seq varint, no body. + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { bogus }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=zstd;failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + StringAssert.Contains("missing prelude", ex.Message); + } + + [Test] + public async Task ZstdBatch_EmptyCompressedBody_Throws() + { + // Valid prelude, FlagZstd set, but no compressed body bytes follow. + var payload = new byte[10]; + payload[0] = QwpConstants.MsgKindResultBatch; + // request_id = 1 + BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(1, 8), 1L); + payload[9] = 0x00; // batch_seq varint = 0 + + var frame = new byte[QwpConstants.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(0, 4), QwpConstants.Magic); + frame[QwpConstants.OffsetVersion] = 1; + frame[QwpConstants.OffsetFlags] = QwpConstants.FlagZstd; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2), 1); + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetPayloadLength, 4), (uint)payload.Length); + payload.CopyTo(frame, QwpConstants.HeaderSize); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { frame }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=zstd;failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + StringAssert.Contains("empty compressed body", ex.Message); + } + + [Test] + public async Task ZstdBatch_DecompressedSizeOverCap_Throws() + { + // Regression for C1: oversized declared content size must be rejected before truncation. + // Construct a zstd frame whose declared size exceeds MaxResultBatchWireBytes. + var oversize = QwpConstants.MaxResultBatchWireBytes + 1; + var payload = BuildZstdBatchPayloadCompressing(new byte[oversize]); + + var frame = new byte[QwpConstants.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(0, 4), QwpConstants.Magic); + frame[QwpConstants.OffsetVersion] = 1; + frame[QwpConstants.OffsetFlags] = QwpConstants.FlagZstd; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2), 1); + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetPayloadLength, 4), (uint)payload.Length); + payload.CopyTo(frame, QwpConstants.HeaderSize); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { frame }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=zstd;failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + StringAssert.Contains("exceeds", ex.Message); + } + + [Test] + public async Task ZstdBatch_CorruptCompressedBody_Throws() + { + // Valid prelude + garbage where a zstd frame should be. + var payload = new byte[10 + 16]; + payload[0] = QwpConstants.MsgKindResultBatch; + BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(1, 8), 1L); + payload[9] = 0x00; + for (var i = 10; i < payload.Length; i++) payload[i] = 0xAB; + + var frame = new byte[QwpConstants.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(0, 4), QwpConstants.Magic); + frame[QwpConstants.OffsetVersion] = 1; + frame[QwpConstants.OffsetFlags] = QwpConstants.FlagZstd; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2), 1); + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetPayloadLength, 4), (uint)payload.Length); + payload.CopyTo(frame, QwpConstants.HeaderSize); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { frame }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=zstd;failover=off;")); + Assert.Catch(() => client.Execute("SELECT 1", new RecordingHandler())); + } + + [Test] + public async Task MixedRawAndZstdBatches_InSameQuery_BothDecode() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var dataA = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(11L) } } }; + var dataB = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(22L) } } }; + + var rawBatch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, dataA); + var rawBatchB = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 1L, new ResultSchema { Columns = schema.Columns }, dataB); + var zstdBatchB = QwpEgressFrameBuilder.CompressResultBatch(rawBatchB); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 1L, 2L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { rawBatch, zstdBatchB, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "compression=zstd;")); + var handler = new RecordingHandler(); + client.Execute("SELECT c", handler); + + Assert.That(handler.Batches.Count, Is.EqualTo(2)); + Assert.That(handler.Batches[0].LongValues, Is.EqualTo(new[] { 11L })); + Assert.That(handler.Batches[1].LongValues, Is.EqualTo(new[] { 22L })); + Assert.That(handler.Ended, Is.True); + } + + private static byte[] BuildZstdBatchPayloadCompressing(byte[] body) + { + // Prelude: msg_kind + request_id(1) + batch_seq varint(0) + const int preludeLen = 1 + 8 + 1; + using var compressor = new ZstdSharp.Compressor(level: 1); + var bound = ZstdSharp.Compressor.GetCompressBound(body.Length); + var compressed = new byte[bound]; + var written = compressor.Wrap(body, compressed); + + var payload = new byte[preludeLen + written]; + payload[0] = QwpConstants.MsgKindResultBatch; + BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(1, 8), 1L); + payload[9] = 0x00; + compressed.AsSpan(0, written).CopyTo(payload.AsSpan(preludeLen)); + return payload; + } + + [Test] + public async Task BatchSeq_NonMonotonic_Rejected() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } }, + }; + + // First batch is fine (seq 0), then jump to 2 instead of 1. + var batch0 = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var batch2 = QwpEgressFrameBuilder.BuildResultBatch(1L, 2L, schema, data); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { batch0, batch2 }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT c", new RecordingHandler())); + Assert.That(ex!.Message, Does.Contain("out-of-order")); + Assert.That(ex.Message, Does.Contain("expected batch_seq=1")); + } + + [Test] + public async Task BatchSeq_StartsAtZero_FirstBatchOneRejected() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } }, + }; + var firstBatchSeqOne = QwpEgressFrameBuilder.BuildResultBatch(1L, 1L, schema, data); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => new[] { firstBatchSeqOne }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server, "failover=off;")); + var ex = Assert.Throws(() => client.Execute("SELECT c", new RecordingHandler())); + Assert.That(ex!.Message, Does.Contain("arrived before the schema-bearing batch_seq=0")); + } + + [Test] + public async Task CorruptVarintInResultBatch_IsTerminalProtocolViolation_NotRetried() + { + // Regression (M3): a malformed varint inside a RESULT_BATCH is structural frame corruption. + // The shared QwpVarint.Read classifies it as the retryable ProtocolVersionError, but inside + // the decode path it must become the terminal ProtocolViolation — under failover=on the + // client must NOT reconnect and re-issue the query against the deterministically-corrupt frame. + var payload = new byte[1 + 8 + 11]; + payload[0] = QwpConstants.MsgKindResultBatch; + BinaryPrimitives.WriteInt64LittleEndian(payload.AsSpan(1, 8), 1L); // request_id + for (var i = 9; i < payload.Length; i++) payload[i] = 0x80; // overlong batch_seq varint + + var frame = new byte[QwpConstants.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(0, 4), QwpConstants.Magic); + frame[QwpConstants.OffsetVersion] = 1; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2), 1); + BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetPayloadLength, 4), (uint)payload.Length); + payload.CopyTo(frame, QwpConstants.HeaderSize); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = f => + f.Length > 0 && f[0] == QwpConstants.MsgKindQueryRequest + ? new[] { frame } + : Array.Empty(), + }); + await server.StartAsync(); + + var conn = BuildConnString(server, + "target=any;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"); + using var client = QueryClient.New(conn); + + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + Assert.That(server.UpgradeCount, Is.EqualTo(1), + "corrupt-frame ProtocolViolation is terminal — failover must not reconnect"); + } + + [Test] + public async Task CorruptVarintInResultEnd_IsTerminalProtocolViolation_NotRetried() + { + // Regression (M2): a malformed varint in a RESULT_END terminator is structural frame corruption. + // QwpVarint.Read classifies it as the retryable ProtocolVersionError, but here it must surface as + // the terminal ProtocolViolation — under failover=on the client must NOT reconnect and re-issue + // the query against the deterministically-corrupt frame. + var frame = QwpEgressFrameBuilder.BuildResultEnd(1L, finalSeq: 0L, totalRows: 0L); + frame[^1] = 0x80; // total_rows varint: continuation bit set with no following byte -> truncated + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = f => + f.Length > 0 && f[0] == QwpConstants.MsgKindQueryRequest + ? new[] { frame } + : Array.Empty(), + }); + await server.StartAsync(); + + var conn = BuildConnString(server, + "target=any;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"); + using var client = QueryClient.New(conn); + + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + Assert.That(server.UpgradeCount, Is.EqualTo(1), + "corrupt-frame ProtocolViolation is terminal — failover must not reconnect"); + } + + [Test] + public async Task CorruptVarintInExecDone_IsTerminalProtocolViolation_NotRetried() + { + // Regression (M2): sibling of the RESULT_END case for the EXEC_DONE terminator's rows_affected varint. + var frame = QwpEgressFrameBuilder.BuildExecDone(1L, opType: 0, rowsAffected: 0L); + frame[^1] = 0x80; // rows_affected varint: continuation bit set with no following byte -> truncated + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = f => + f.Length > 0 && f[0] == QwpConstants.MsgKindQueryRequest + ? new[] { frame } + : Array.Empty(), + }); + await server.StartAsync(); + + var conn = BuildConnString(server, + "target=any;failover=on;failover_max_attempts=4;" + + "failover_backoff_initial_ms=10;failover_backoff_max_ms=20;"); + using var client = QueryClient.New(conn); + + var ex = Assert.Throws(() => client.Execute("INSERT INTO t VALUES(1)", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + Assert.That(server.UpgradeCount, Is.EqualTo(1), + "corrupt-frame ProtocolViolation is terminal — failover must not reconnect"); + } + + [Test] + public async Task HandlerThrowOnExecDone_DoesNotMarkClientTerminal() + { + var done1 = QwpEgressFrameBuilder.BuildExecDone(1L, opType: 1, rowsAffected: 5L); + var done2 = QwpEgressFrameBuilder.BuildExecDone(2L, opType: 1, rowsAffected: 5L); + + var responses = new Queue(new[] + { + new[] { done1 }, + new[] { done2 }, + }); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => responses.Dequeue(), + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + + var throwing = new RecordingHandler(); + throwing.OnExecDoneHook = () => throw new InvalidOperationException("exec handler boom"); + Assert.Throws(() => client.Execute("INSERT ...", throwing)); + + var second = new RecordingHandler(); + Assert.DoesNotThrow(() => client.Execute("INSERT ...", second)); + Assert.That(second.LastExecRowsAffected, Is.EqualTo(5L)); + } + + [Test] + public async Task HandlerThrowOnEnd_DoesNotMarkClientTerminal() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongLe(7L) } }, + }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + var batch2 = QwpEgressFrameBuilder.BuildResultBatch(2L, 0L, schema, data); + var end2 = QwpEgressFrameBuilder.BuildResultEnd(2L, 0L, 1L); + + var responses = new Queue(new[] + { + new[] { batch, end }, + new[] { batch2, end2 }, + }); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = _ => responses.Dequeue(), + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + + var throwing = new RecordingHandler(); + throwing.OnEndHook = () => throw new InvalidOperationException("handler boom"); + Assert.Throws(() => client.Execute("SELECT c", throwing)); + + // Connection is wire-side healthy: subsequent Execute should succeed. + var second = new RecordingHandler(); + Assert.DoesNotThrow(() => client.Execute("SELECT c", second)); + Assert.That(second.Ended, Is.True); + Assert.That(second.TotalRows, Is.EqualTo(1L)); + } + + private static string BuildConnString(DummyQwpServer server, string extra = "") + { + var addr = server.Uri.Authority; + return $"ws::addr={addr};path={QwpConstants.ReadPath};{extra}"; + } + + private static byte[] LongLe(long value) + { + var bytes = new byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(bytes, value); + return bytes; + } + + private static byte[] LongsLe(params long[] values) + { + var bytes = new byte[values.Length * 8]; + for (var i = 0; i < values.Length; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(i * 8, 8), values[i]); + } + return bytes; + } + + private sealed class RecordingHandler : QwpColumnBatchHandler + { + public sealed record CapturedBatch(long RequestId, long BatchSeq, int RowCount, long[] LongValues); + + public List Batches { get; } = new(); + public bool Ended { get; private set; } + public long TotalRows { get; private set; } + public byte LastErrorStatus { get; private set; } + public string LastErrorMessage { get; private set; } = string.Empty; + public byte LastExecOpType { get; private set; } + public long LastExecRowsAffected { get; private set; } + public List FailoverResets { get; } = new(); + public Action? OnBatchHook { get; set; } + public Action? OnEndHook { get; set; } + public Action? OnExecDoneHook { get; set; } + + public override void OnBatch(QwpColumnBatch batch) + { + var rows = new long[batch.RowCount]; + if (batch.ColumnCount > 0 && batch.GetColumnWireType(0) == QwpTypeCode.Long) + { + for (var r = 0; r < batch.RowCount; r++) rows[r] = batch.GetLongValue(0, r); + } + Batches.Add(new CapturedBatch(batch.RequestId, batch.BatchSeq, batch.RowCount, rows)); + OnBatchHook?.Invoke(batch); + } + + public override void OnEnd(long totalRows) + { + Ended = true; + TotalRows = totalRows; + OnEndHook?.Invoke(); + } + + public override void OnError(QwpStatusCode status, string message) + { + LastErrorStatus = (byte)status; + LastErrorMessage = message; + } + + public override void OnExecDone(QwpOpType opType, long rowsAffected) + { + LastExecOpType = (byte)opType; + LastExecRowsAffected = rowsAffected; + OnExecDoneHook?.Invoke(); + } + + public override void OnFailoverReset(QwpServerInfo? newNode) + { + FailoverResets.Add(newNode); + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientTlsTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientTlsTests.cs new file mode 100644 index 00000000..16853b13 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientTlsTests.cs @@ -0,0 +1,156 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; +using QuestDB.Utils; +using dummy_http_server; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QwpQueryClientTlsTests +{ + [Test] + public async Task Tls_SelfSignedCert_VerifyOff_ConnectsAndRunsQuery() + { + using var cert = NewSelfSignedCertificate("CN=localhost"); + + var schema = new ResultSchema + { + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongLe(42L) } }, + }; + var batch = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + var end = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + TlsCertificate = cert, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await server.StartAsync(); + + using var client = QueryClient.New( + $"wss::addr={server.Uri.Authority};path={QwpConstants.ReadPath};tls_verify=unsafe_off;"); + var handler = new RecordingHandler(); + client.Execute("SELECT 42", handler); + + Assert.That(handler.Batches.Count, Is.EqualTo(1)); + Assert.That(handler.Batches[0].LongValues, Is.EqualTo(new[] { 42L })); + Assert.That(handler.Ended, Is.True); + } + + [Test] + public async Task Tls_SelfSignedCert_VerifyOn_ConnectFails() + { + using var cert = NewSelfSignedCertificate("CN=localhost"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + TlsCertificate = cert, + FrameHandlerMulti = _ => Array.Empty(), + }); + await server.StartAsync(); + + Assert.Catch(() => + QueryClient.New($"wss::addr={server.Uri.Authority};path={QwpConstants.ReadPath};tls_verify=on;")); + } + + private static System.Security.Cryptography.X509Certificates.X509Certificate2 NewSelfSignedCertificate(string subject) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + sanBuilder.AddIpAddress(IPAddress.Parse("127.0.0.1")); + req.CertificateExtensions.Add(sanBuilder.Build()); + + var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); + // Kestrel needs a cert with an exportable private key. Keep the byte-array constructor for + // net6.0–net8.0 compatibility; X509CertificateLoader is net9+ only. +#pragma warning disable SYSLIB0057 + var pfx = ephemeral.Export(X509ContentType.Pfx); + return new X509Certificate2(pfx, (string?)null, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 + } + + private static byte[] LongLe(long value) + { + var bytes = new byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(bytes, value); + return bytes; + } + + private sealed class RecordingHandler : QwpColumnBatchHandler + { + public sealed record CapturedBatch(int RowCount, long[] LongValues); + + public List Batches { get; } = new(); + public bool Ended { get; private set; } + + public override void OnBatch(QwpColumnBatch batch) + { + var rows = new long[batch.RowCount]; + if (batch.ColumnCount > 0 && batch.GetColumnWireType(0) == QwpTypeCode.Long) + { + for (var r = 0; r < batch.RowCount; r++) rows[r] = batch.GetLongValue(0, r); + } + Batches.Add(new CapturedBatch(batch.RowCount, rows)); + } + + public override void OnEnd(long totalRows) + { + Ended = true; + } + + public override void OnError(QwpStatusCode status, string message) + { + } + + public override void OnExecDone(QwpOpType opType, long rowsAffected) + { + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpResultBatchDecoderTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpResultBatchDecoderTests.cs new file mode 100644 index 00000000..5e9bb85d --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpResultBatchDecoderTests.cs @@ -0,0 +1,1454 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Query; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QwpResultBatchDecoderTests +{ + [Test] + public void Decode_LongColumnFullSchema_RoundTrips() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("id", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 3, + Columns = { new FixedColumnData { DenseBytes = LongsLe(1L, 2L, 3L) } }, + }; + + var (state, batch, headerFlags, payload) = DecodeOneBatch(schema, data); + + Assert.That(batch.RowCount, Is.EqualTo(3)); + Assert.That(batch.ColumnCount, Is.EqualTo(1)); + Assert.That(batch.GetColumnName(0), Is.EqualTo("id")); + Assert.That(batch.GetColumnWireType(0), Is.EqualTo(QwpTypeCode.Long)); + Assert.That(batch.GetLongValue(0, 0), Is.EqualTo(1L)); + Assert.That(batch.GetLongValue(0, 1), Is.EqualTo(2L)); + Assert.That(batch.GetLongValue(0, 2), Is.EqualTo(3L)); + Assert.That(batch.IsNull(0, 0), Is.False); + } + + [Test] + public void Decode_NullsViaBitmap_AreVisibleAndDenseValuesShifted() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("v", QwpTypeCode.Int) }, + }; + var data = new ResultBatchData + { + RowCount = 4, + // bits LSB-first per byte: row 0 null, row 1 set, row 2 null, row 3 set → bitmap byte = 0b0000_0101 + Columns = { new FixedColumnData { NullBitmap = new byte[] { 0b0000_0101 }, DenseBytes = IntsLe(20, 40) } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.IsNull(0, 0), Is.True); + Assert.That(batch.IsNull(0, 1), Is.False); + Assert.That(batch.IsNull(0, 2), Is.True); + Assert.That(batch.IsNull(0, 3), Is.False); + Assert.That(batch.GetIntValue(0, 1), Is.EqualTo(20)); + Assert.That(batch.GetIntValue(0, 3), Is.EqualTo(40)); + Assert.That(batch.GetIntValue(0, 0), Is.EqualTo(0)); + } + + [Test] + public void Decode_VarcharColumn_RoundTrips() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("s", QwpTypeCode.Varchar) }, + }; + var data = new ResultBatchData + { + RowCount = 3, + Columns = { new VarcharColumnData { DenseValues = new[] { "a", "bb", "ccc" } } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetString(0, 0), Is.EqualTo("a")); + Assert.That(batch.GetString(0, 1), Is.EqualTo("bb")); + Assert.That(batch.GetString(0, 2), Is.EqualTo("ccc")); + var span = batch.GetStringSpan(0, 1); + Assert.That(Encoding.UTF8.GetString(span), Is.EqualTo("bb")); + } + + [Test] + public void Decode_SymbolWithDeltaDict_BuildsConnDictThenResolves() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("sym", QwpTypeCode.Symbol) }, + }; + var data = new ResultBatchData + { + RowCount = 4, + Columns = { new SymbolColumnData { DenseDictIds = new[] { 0, 1, 0, 2 } } }, + }; + var dict = new DeltaSymbolDict { DeltaStart = 0, Entries = { "alpha", "beta", "gamma" } }; + + var (state, batch, _, _) = DecodeOneBatch(schema, data, dict); + + Assert.That(batch.GetSymbolDictSize(0), Is.EqualTo(3)); + Assert.That(batch.GetSymbol(0, 0), Is.EqualTo("alpha")); + Assert.That(batch.GetSymbol(0, 1), Is.EqualTo("beta")); + Assert.That(batch.GetSymbol(0, 2), Is.EqualTo("alpha")); + Assert.That(batch.GetSymbol(0, 3), Is.EqualTo("gamma")); + Assert.That(batch.GetSymbolId(0, 1), Is.EqualTo(1)); + } + + [Test] + public void Decode_TimestampGorilla_RoundTrips() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("ts", QwpTypeCode.Timestamp) }, + }; + var values = new[] { 1_700_000_000_000_000L, 1_700_000_000_000_100L, 1_700_000_000_000_200L, 1_700_000_000_000_300L }; + var data = new ResultBatchData + { + RowCount = values.Length, + Columns = { new TimestampColumnData { DenseValues = values } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + for (var i = 0; i < values.Length; i++) + { + Assert.That(batch.GetTimestampValue(0, i), Is.EqualTo(values[i])); + } + } + + [Test] + public void Decode_ContinuationBatch_ReusesSchemaFromBatch0() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var dataA = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(7L) } } }; + var frameA = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, dataA); + + var batchA = new QwpColumnBatch(); + decoder.Decode(PayloadOf(frameA).Span, HeaderFlagsOf(frameA), batchA); + Assert.That(batchA.GetLongValue(0, 0), Is.EqualTo(7L)); + + var dataB = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(42L) } } }; + var frameB = QwpEgressFrameBuilder.BuildResultBatch(1L, 1L, schema, dataB); + + var batchB = new QwpColumnBatch(); + decoder.Decode(PayloadOf(frameB).Span, HeaderFlagsOf(frameB), batchB); + Assert.That(batchB.GetLongValue(0, 0), Is.EqualTo(42L)); + Assert.That(batchB.GetColumnName(0), Is.EqualTo("c")); + } + + [Test] + public void Decode_ContinuationBeforeBatch0_Throws() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + // No batch_seq=0 has been delivered; a continuation batch must be rejected rather + // than binding rows to a stale schema. + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(1L) } } }; + var frame = QwpEgressFrameBuilder.BuildResultBatch(1L, 5L, schema, data); + + Assert.Throws(() => decoder.Decode(PayloadOf(frame).Span, HeaderFlagsOf(frame), new QwpColumnBatch())); + } + + [Test] + public void ResetQuerySchema_InvalidatesPriorBatch0Schema() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + // Query 1 starts: batch_seq=0 carries the schema. + var schema = new ResultSchema { Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var dataA = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(1L) } } }; + decoder.Decode(PayloadOf(QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, dataA)).Span, + HeaderFlagsOf(QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, dataA)), + new QwpColumnBatch()); + + // Query 2 starts: query schema invalidated, a continuation batch from the new request + // must NOT bind to query 1's schema. + decoder.ResetQuerySchema(); + var dataB = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(2L) } } }; + var frame = QwpEgressFrameBuilder.BuildResultBatch(2L, 1L, schema, dataB); + + Assert.Throws(() => decoder.Decode(PayloadOf(frame).Span, HeaderFlagsOf(frame), new QwpColumnBatch())); + } + + [Test] + public void Decode_TruncatedPayload_Throws() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData { RowCount = 2, Columns = { new FixedColumnData { DenseBytes = LongsLe(1L, 2L) } } }; + var frame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + + var truncatedPayload = PayloadOf(frame); + var shortPayloadBytes = truncatedPayload.Slice(0, truncatedPayload.Length - 4).ToArray(); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + Assert.Throws(() => decoder.Decode(shortPayloadBytes, HeaderFlagsOf(frame), new QwpColumnBatch())); + } + + [Test] + public void Decode_DecimalColumn_CarriesScalePrefix() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("d", QwpTypeCode.Decimal64) }, + }; + var dense = LongsLe(12345L); + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 2, DenseBytes = dense } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetDecimalScale(0), Is.EqualTo((byte)2)); + Assert.That(batch.GetDecimal64UnscaledValue(0, 0), Is.EqualTo(12345L)); + } + + [Test] + public void Decode_DoubleArrayColumn_RoundTripsRowMajor() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("a", QwpTypeCode.DoubleArray) }, + }; + var data = new ResultBatchData + { + RowCount = 2, + Columns = + { + new DoubleArrayColumnData + { + DenseArrays = new[] + { + (new[] { 3 }, new[] { 1.0, 2.0, 3.0 }), + (new[] { 2, 2 }, new[] { 10.0, 20.0, 30.0, 40.0 }), + }, + }, + }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetArrayNDims(0, 0), Is.EqualTo(1)); + Assert.That(batch.GetArrayShape(0, 0), Is.EqualTo(new[] { 3 })); + Assert.That(batch.GetDoubleArrayElements(0, 0), Is.EqualTo(new[] { 1.0, 2.0, 3.0 })); + + Assert.That(batch.GetArrayNDims(0, 1), Is.EqualTo(2)); + Assert.That(batch.GetArrayShape(0, 1), Is.EqualTo(new[] { 2, 2 })); + Assert.That(batch.GetDoubleArrayElements(0, 1), Is.EqualTo(new[] { 10.0, 20.0, 30.0, 40.0 })); + } + + [Test] + public void Decode_LongArrayColumn_RoundTripsRowMajor() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("la", QwpTypeCode.LongArray) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = + { + new LongArrayColumnData + { + DenseArrays = new[] { (new[] { 4 }, new[] { 1L, 2L, 3L, 4L }) }, + }, + }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetLongArrayElements(0, 0), Is.EqualTo(new[] { 1L, 2L, 3L, 4L })); + } + + [Test] + public void ArrayElementAccessors_OnMismatchedArrayType_Throw() + { + // A DOUBLE_ARRAY column must reject GetLong* and a LONG_ARRAY column must reject GetDouble*. + // Both element widths are 8 bytes, so without an exact-type guard the bytes would be silently + // reinterpreted (longs as doubles / vice versa) and return wrong data with no error. + var doubleSchema = new ResultSchema + { + Columns = { new SchemaColumn("a", QwpTypeCode.DoubleArray) }, + }; + var doubleData = new ResultBatchData + { + RowCount = 1, + Columns = + { + new DoubleArrayColumnData + { + DenseArrays = new[] { (new[] { 3 }, new[] { 1.0, 2.0, 3.0 }) }, + }, + }, + }; + var (_, doubleBatch, _, _) = DecodeOneBatch(doubleSchema, doubleData); + Assert.Throws(() => doubleBatch.GetLongArraySpan(0, 0).ToArray()); + Assert.Throws(() => doubleBatch.GetLongArrayElements(0, 0)); + // Type-agnostic accessors still work on either array type. + Assert.That(doubleBatch.GetArrayShape(0, 0), Is.EqualTo(new[] { 3 })); + Assert.That(doubleBatch.GetDoubleArrayElements(0, 0), Is.EqualTo(new[] { 1.0, 2.0, 3.0 })); + + var longSchema = new ResultSchema + { + Columns = { new SchemaColumn("la", QwpTypeCode.LongArray) }, + }; + var longData = new ResultBatchData + { + RowCount = 1, + Columns = + { + new LongArrayColumnData + { + DenseArrays = new[] { (new[] { 4 }, new[] { 1L, 2L, 3L, 4L }) }, + }, + }, + }; + var (_, longBatch, _, _) = DecodeOneBatch(longSchema, longData); + Assert.Throws(() => longBatch.GetDoubleArraySpan(0, 0).ToArray()); + Assert.Throws(() => longBatch.GetDoubleArrayElements(0, 0)); + Assert.That(longBatch.GetArrayShape(0, 0), Is.EqualTo(new[] { 4 })); + Assert.That(longBatch.GetLongArrayElements(0, 0), Is.EqualTo(new[] { 1L, 2L, 3L, 4L })); + } + + [Test] + public void Decode_GeohashColumn_RoundTripsPrecisionAndValue() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("g", QwpTypeCode.Geohash) }, + }; + var dense = new byte[] { 0xAB, 0xCD, 0xEF }; // 24 bits = 3 bytes per row, little-endian + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new GeohashColumnData { PrecisionBits = 24, DenseBytes = dense } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetGeohashPrecisionBits(0), Is.EqualTo(24)); + Assert.That(batch.GetGeohashValue(0, 0), Is.EqualTo(0x00EFCDABL)); + } + + [Test] + public void Decode_GeohashColumn_NullRow_ReturnsMinusOne() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("g", QwpTypeCode.Geohash) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new GeohashColumnData { PrecisionBits = 24, NullBitmap = new byte[] { 0b01 }, DenseBytes = Array.Empty() } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.IsNull(0, 0), Is.True); + Assert.That(batch.GetGeohashValue(0, 0), Is.EqualTo(-1L)); + } + + private static (QwpEgressConnState State, QwpColumnBatch Batch, byte HeaderFlags, ReadOnlyMemory Payload) + DecodeOneBatch(ResultSchema schema, ResultBatchData data, DeltaSymbolDict? dict = null) + { + var frame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data, dict); + var headerFlags = HeaderFlagsOf(frame); + var payload = PayloadOf(frame); + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + var batch = new QwpColumnBatch(); + decoder.Decode(payload.Span, headerFlags, batch); + return (state, batch, headerFlags, payload); + } + + [Test] + public void Decode_TwoBatchesIntoSameTarget_NoStaleResidueFromFirst() + { + // Regression: scratch pooling must not leak prior-batch data into the next read. + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + var target = new QwpColumnBatch(); + + var schema = new ResultSchema + { + Columns = + { + new SchemaColumn("id", QwpTypeCode.Long), + new SchemaColumn("label", QwpTypeCode.Varchar), + }, + }; + + var idsA = new long[100]; + var labelsA = new string[100]; + for (var i = 0; i < 100; i++) + { + idsA[i] = 1000 + i; + labelsA[i] = "rA-" + i; + } + var dataA = new ResultBatchData + { + RowCount = 100, + Columns = + { + new FixedColumnData { DenseBytes = LongsLe(idsA) }, + new VarcharColumnData { DenseValues = labelsA }, + }, + }; + var frameA = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, dataA); + decoder.Decode(PayloadOf(frameA).Span, HeaderFlagsOf(frameA), target); + + Assert.That(target.RowCount, Is.EqualTo(100)); + Assert.That(target.GetLongValue(0, 0), Is.EqualTo(1000L)); + Assert.That(target.GetLongValue(0, 99), Is.EqualTo(1099L)); + + // Batch B: shorter (50 rows) + mid-batch nulls — exercises pooled NonNullIndex / shorter row range. + var idsB = new List(); + var labelsB = new List(); + var nullBitmap = new byte[(50 + 7) >> 3]; + for (var i = 0; i < 50; i++) + { + if (i % 2 == 0) + { + nullBitmap[i >> 3] |= (byte)(1 << (i & 7)); + labelsB.Add(""); // doesn't appear; null filtered + } + else + { + idsB.Add(2000 + i); + labelsB.Add("rB-" + i); + } + } + var dataB = new ResultBatchData + { + RowCount = 50, + Columns = + { + new FixedColumnData { DenseBytes = LongsLe(idsB.ToArray()), NullBitmap = nullBitmap }, + new VarcharColumnData + { + NullBitmap = nullBitmap, + DenseValues = Enumerable.Range(0, 50) + .Where(i => i % 2 != 0) + .Select(i => "rB-" + i) + .ToArray(), + }, + }, + }; + var frameB = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 1L, + new ResultSchema { Columns = schema.Columns }, + dataB); + decoder.Decode(PayloadOf(frameB).Span, HeaderFlagsOf(frameB), target); + + Assert.That(target.RowCount, Is.EqualTo(50)); + for (var i = 0; i < 50; i++) + { + if (i % 2 == 0) + { + Assert.That(target.IsNull(0, i), Is.True, $"row {i} col 0 should be null"); + } + else + { + Assert.That(target.IsNull(0, i), Is.False, $"row {i} col 0 should not be null"); + Assert.That(target.GetLongValue(0, i), Is.EqualTo(2000L + i), + $"row {i} col 0 leaked stale data from batch A"); + } + } + + for (var i = 0; i < 50; i++) + { + if (i % 2 != 0) + { + var got = Encoding.UTF8.GetString(target.GetStringSpan(1, i)); + Assert.That(got, Is.EqualTo("rB-" + i), + $"row {i} col 1 leaked stale label from batch A"); + } + } + } + + [Test] + public void Decode_IPv4Column_DecodesAsInt() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("ip", QwpTypeCode.IPv4) }, + }; + var data = new ResultBatchData + { + RowCount = 2, + Columns = { new FixedColumnData { DenseBytes = IntsLe(unchecked((int)0xC0A80101), 0x7F000001) } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.RowCount, Is.EqualTo(2)); + Assert.That(batch.GetColumnWireType(0), Is.EqualTo(QwpTypeCode.IPv4)); + Assert.That(batch.GetIPv4Value(0, 0), Is.EqualTo(unchecked((int)0xC0A80101))); + Assert.That(batch.GetIPv4Value(0, 1), Is.EqualTo(0x7F000001)); + Assert.That(batch.GetString(0, 0), Is.EqualTo("192.168.1.1")); + Assert.That(batch.GetString(0, 1), Is.EqualTo("127.0.0.1")); + } + + [Test] + public void Decode_BinaryColumn_RoundTrips() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("blob", QwpTypeCode.Binary) }, + }; + var values = new[] + { + new byte[] { 0x00, 0x01, 0x02 }, + Array.Empty(), + new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, + }; + var data = new ResultBatchData + { + RowCount = values.Length, + Columns = { new BinaryColumnData { DenseValues = values } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetColumnWireType(0), Is.EqualTo(QwpTypeCode.Binary)); + Assert.That(batch.GetBinarySpan(0, 0).ToArray(), Is.EqualTo(values[0])); + Assert.That(batch.GetBinarySpan(0, 1).ToArray(), Is.EqualTo(values[1])); + Assert.That(batch.GetBinarySpan(0, 2).ToArray(), Is.EqualTo(values[2])); + Assert.That(batch.GetString(0, 2), Is.EqualTo("DEADBEEF")); + } + + [Test] + public void Decode_UuidColumn_RoundTripsWithGetUuid() + { + var lo = 0x0123_4567_89AB_CDEFL; + var hi = unchecked((long)0xFEDC_BA98_7654_3210UL); + var bytes = new byte[16]; + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(0, 8), lo); + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(8, 8), hi); + + var schema = new ResultSchema + { + Columns = { new SchemaColumn("u", QwpTypeCode.Uuid) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = bytes } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetColumnWireType(0), Is.EqualTo(QwpTypeCode.Uuid)); + Assert.That(batch.GetUuidLo(0, 0), Is.EqualTo(lo)); + Assert.That(batch.GetUuidHi(0, 0), Is.EqualTo(hi)); + + var binds = new QwpBindValues(); + binds.SetUuid(0, batch.GetUuid(0, 0)); + Assert.That(binds.AsMemory().Span[0], Is.EqualTo((byte)QwpTypeCode.Uuid)); + var roundTripped = new byte[16]; + binds.AsMemory().Span.Slice(2, 16).CopyTo(roundTripped); + Assert.That(roundTripped, Is.EqualTo(bytes)); + } + + [Test] + public void GetBinarySpan_OnNonBinaryColumn_Throws() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("v", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongsLe(42L) } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.Throws(() => batch.GetBinarySpan(0, 0).ToArray()); + } + + [Test] + public void ArrayAndSymbolAccessors_OnWrongTypedColumn_Throw() + { + // On a type-mismatched column (server schema drift, or app hard-coding the wrong type) these + // must surface InvalidOperationException like the other typed accessors (GetBinarySpan / + // GetStringSpan / GetLong256), not a raw NullReferenceException from dereferencing the wrong + // backing array (SymbolIds / StringOffsets). + var schema = new ResultSchema + { + Columns = { new SchemaColumn("v", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongsLe(42L) } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.Throws(() => batch.GetSymbolId(0, 0)); + Assert.Throws(() => batch.GetDoubleArraySpan(0, 0).ToArray()); + Assert.Throws(() => batch.GetLongArraySpan(0, 0).ToArray()); + Assert.Throws(() => batch.GetArrayShape(0, 0)); + Assert.Throws(() => batch.GetArrayNDims(0, 0)); + } + + [Test] + public void NumericAccessors_OnWrongTypedColumn_Throw() + { + // A VARCHAR column decodes into StringHeap/StringOffsets and never touches the ValueBytes + // scratch — which is pooled across batches (ColumnView.Reset clears sentinels, not scratch). + // Without a type guard a numeric accessor reads stale ValueBytes residue from a prior batch + // and returns a plausible-looking wrong value (or an ArgumentOutOfRangeException on an empty + // scratch) instead of throwing, the silent-wrong-data failure 32d8f00 set out to eliminate. + var schema = new ResultSchema + { + Columns = { new SchemaColumn("s", QwpTypeCode.Varchar) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new VarcharColumnData { DenseValues = new[] { "x" } } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.Throws(() => batch.GetBoolValue(0, 0)); + Assert.Throws(() => batch.GetByteValue(0, 0)); + Assert.Throws(() => batch.GetSByteValue(0, 0)); + Assert.Throws(() => batch.GetShortValue(0, 0)); + Assert.Throws(() => batch.GetCharValue(0, 0)); + Assert.Throws(() => batch.GetIntValue(0, 0)); + Assert.Throws(() => batch.GetLongValue(0, 0)); + Assert.Throws(() => batch.GetFloatValue(0, 0)); + Assert.Throws(() => batch.GetDoubleValue(0, 0)); + // Width-family wrappers and the IPv4/timestamp/date convenience readers share the same guard. + Assert.Throws(() => batch.GetIPv4Value(0, 0)); + Assert.Throws(() => batch.GetTimestampValue(0, 0)); + Assert.Throws(() => batch.GetDateValue(0, 0)); + } + + [Test] + public void NumericAccessors_AcceptCompatibleWireTypeFamily() + { + // GetLongValue is the int64 reader for the whole long family (a designated TIMESTAMP / DATE + // is read through it), and GetIntValue covers Int + IPv4. GetString and the convenience + // wrappers depend on that, so the guard must admit the family, not just the canonical code. + var schema = new ResultSchema + { + Columns = + { + new SchemaColumn("ts", QwpTypeCode.Timestamp), + new SchemaColumn("d", QwpTypeCode.Date), + new SchemaColumn("ip", QwpTypeCode.IPv4), + }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = + { + new TimestampColumnData { DenseValues = new[] { 1_700_000_000_000_000L } }, + new TimestampColumnData { DenseValues = new[] { 123_456L } }, + new FixedColumnData { DenseBytes = IntsLe(0x7F000001) }, + }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + Assert.That(batch.GetLongValue(0, 0), Is.EqualTo(1_700_000_000_000_000L)); + Assert.That(batch.GetLongValue(1, 0), Is.EqualTo(123_456L)); + Assert.That(batch.GetIntValue(2, 0), Is.EqualTo(0x7F000001)); + } + + [Test] + public void GetString_OnInvalidUtf8Varchar_RendersReplacementCharWithoutThrowing() + { + // The decoder copies VARCHAR/SYMBOL heap bytes verbatim, so GetString is the first UTF-8 + // decode point. A server value with invalid UTF-8 (a lone 0xFF) must render lossily via the + // replacement fallback, not throw a raw DecoderFallbackException out of OnBatch. + // VARCHAR and BINARY share the offsets+heap wire layout, so a Varchar schema column paired + // with BinaryColumnData injects arbitrary heap bytes a string[] builder cannot represent. + var schema = new ResultSchema + { + Columns = { new SchemaColumn("s", QwpTypeCode.Varchar) }, + }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new BinaryColumnData { DenseValues = new[] { new byte[] { 0xFF } } } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + + string? rendered = null; + Assert.DoesNotThrow(() => rendered = batch.GetString(0, 0)); + Assert.That(rendered, Is.EqualTo("�")); // U+FFFD replacement char + // The raw bytes remain available, unchanged, through the span accessor. + Assert.That(batch.GetStringSpan(0, 0).ToArray(), Is.EqualTo(new byte[] { 0xFF })); + } + + [Test] + public void Decode_RejectsHugeColumnNameLength() + { + // Hand-craft a payload whose column name length varint encodes int.MaxValue. + var p = new List + { + QwpConstants.MsgKindResultBatch, + }; + p.AddRange(new byte[8]); // requestId = 0 + p.Add(0x00); // batch_seq = 0 + p.Add(0x00); // empty table name + p.Add(0x01); // row_count + p.Add(0x01); // col_count + // column name length varint (int.MaxValue) + WriteVarintTo(p, 0x7FFFFFFFUL); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("column name length out of range", ex!.Message); + } + + [Test] + public void Decode_RejectsTooManyArrayDimensions() + { + // 200-dim array — exceeds MaxArrayDimensions=32. Use a fully formed but invalid payload. + var p = new List + { + QwpConstants.MsgKindResultBatch, + }; + p.AddRange(new byte[8]); + p.Add(0x00); // batch_seq + p.Add(0x00); // empty table name + p.Add(0x01); // row_count = 1 + p.Add(0x01); // col_count = 1 + p.Add(0x01); // col name len = 1 + p.Add((byte)'a'); + p.Add((byte)QwpTypeCode.DoubleArray); + p.Add(0x00); // null_flag = 0 (no nulls) + p.Add(200); // nDims out of range + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("array nDims out of range", ex!.Message); + } + + [Test] + public void Decode_RejectsNonZeroVarcharOffset0() + { + var p = new List + { + QwpConstants.MsgKindResultBatch, + }; + p.AddRange(new byte[8]); + p.Add(0x00); // batch_seq + p.Add(0x00); // empty table name + p.Add(0x01); // row_count + p.Add(0x01); // col_count + p.Add(0x01); p.Add((byte)'v'); + p.Add((byte)QwpTypeCode.Varchar); + p.Add(0x00); // null_flag = 0 + // offsets[0] = 5 (illegal), offsets[1] = 8 + WriteI32(p, 5); + WriteI32(p, 8); + for (var i = 0; i < 8; i++) p.Add((byte)i); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("offsets[0] must be 0", ex!.Message); + } + + [Test] + public void Decode_RejectsNonMonotonicVarcharOffsets() + { + var p = new List + { + QwpConstants.MsgKindResultBatch, + }; + p.AddRange(new byte[8]); + p.Add(0x00); + p.Add(0x00); + p.Add(0x02); // row_count = 2 + p.Add(0x01); + p.Add(0x01); p.Add((byte)'v'); + p.Add((byte)QwpTypeCode.Varchar); + p.Add(0x00); + WriteI32(p, 0); + WriteI32(p, 5); + WriteI32(p, 3); // non-monotonic + for (var i = 0; i < 5; i++) p.Add((byte)i); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("non-monotonic", ex!.Message); + } + + [Test] + public void Decode_RejectsArrayShapeOverflowingPayload() + { + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); // batch_seq + p.Add(0x00); // empty table name + p.Add(0x01); // row_count + p.Add(0x01); // col_count + p.Add(0x01); p.Add((byte)'a'); + p.Add((byte)QwpTypeCode.DoubleArray); + p.Add(0x00); // null_flag = 0 + p.Add(0x02); // nDims = 2 + WriteI32(p, int.MaxValue); + WriteI32(p, int.MaxValue); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("array shape exceeds remaining payload", ex!.Message); + } + + [Test] + public void Decode_RejectsArrayShapeAccumulatingOverflow() + { + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + p.Add(0x00); + p.Add(0x01); + p.Add(0x01); + p.Add(0x01); p.Add((byte)'a'); + p.Add((byte)QwpTypeCode.DoubleArray); + p.Add(0x00); + p.Add(0x05); // nDims = 5; each dim individually fits, product overflows + WriteI32(p, 1024); + WriteI32(p, 1024); + WriteI32(p, 1024); + WriteI32(p, 1024); + WriteI32(p, 1024); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("array shape exceeds remaining payload", ex!.Message); + } + + [Test] + public void Decode_RejectsVarcharHeapLenOverflow() + { + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + p.Add(0x00); + p.Add(0x01); + p.Add(0x01); + p.Add(0x01); p.Add((byte)'v'); + p.Add((byte)QwpTypeCode.Varchar); + p.Add(0x00); + WriteI32(p, 0); + WriteI32(p, int.MaxValue); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("truncated varchar heap", ex!.Message); + } + + [Test] + public void Decode_RejectsSymbolDictVarintExceedingInt() + { + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + WriteVarintTo(p, ulong.MaxValue); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => + decoder.Decode(p.ToArray(), QwpConstants.FlagDeltaSymbolDict, new QwpColumnBatch())); + StringAssert.Contains("varint exceeds int.MaxValue", ex!.Message); + } + + [Test] + public void Decode_RejectsSymbolIdVarintExceedingInt() + { + // Regression: an oversized symbol-id varint must be rejected, not silently truncated to + // a wrong (aliased) int32 id. The decoder reads symbol ids via ReadBoundedVarintAsInt. + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); // requestId = 0 + p.Add(0x00); // batch_seq = 0 + + // Delta symbol dict with one entry so the column has a non-empty dict to reference. + WriteVarintTo(p, 0); // deltaStart = 0 + WriteVarintTo(p, 1); // deltaCount = 1 + WriteVarintTo(p, 5); // entry length + for (var i = 0; i < 5; i++) p.Add((byte)('a' + i)); + + p.Add(0x00); // empty table name + p.Add(0x01); // row_count = 1 + p.Add(0x01); // col_count = 1 + p.Add(0x01); p.Add((byte)'s'); // column name "s" + p.Add((byte)QwpTypeCode.Symbol); + p.Add(0x00); // null_flag = 0 + WriteVarintTo(p, ulong.MaxValue); // symbol id varint far beyond int.MaxValue + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => + decoder.Decode(p.ToArray(), QwpConstants.FlagDeltaSymbolDict, new QwpColumnBatch())); + StringAssert.Contains("symbol id", ex!.Message); + StringAssert.Contains("varint exceeds int.MaxValue", ex.Message); + } + + [Test] + public void Decode_BooleanWithNullFlagAllZeroBitmap_AcceptedAsNonNullRows() + { + // Spec §11.5: BOOLEAN/BYTE/SHORT/CHAR have no NULL sentinel. null_flag=1 with an + // all-zero bitmap means "no nulls" and must be accepted. + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + p.Add(0x00); + p.Add(0x01); + p.Add(0x01); + p.Add(0x01); p.Add((byte)'b'); + p.Add((byte)QwpTypeCode.Boolean); + p.Add(0x01); // null_flag = 1 + p.Add(0x00); // bitmap: no nulls + p.Add(0x01); // packed bool: row 0 = true + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var batch = new QwpColumnBatch(); + decoder.Decode(p.ToArray(), 0, batch); + Assert.That(batch.GetBoolValue(0, 0), Is.True); + } + + [Test] + public void Decode_FailedBatch_DoesNotPersistSymbolDictAdditions() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("s", QwpTypeCode.Symbol) } }; + + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + WriteVarintTo(p, 0); + WriteVarintTo(p, 1); + WriteVarintTo(p, 5); + for (var i = 0; i < 5; i++) p.Add((byte)('a' + i)); + + p.Add(0x00); + p.Add(0x01); + p.Add(0x01); + p.Add(0x01); p.Add((byte)'s'); + p.Add((byte)QwpTypeCode.Symbol); + p.Add(0x00); + + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + Assert.Throws(() => + decoder.Decode(p.ToArray(), QwpConstants.FlagDeltaSymbolDict, new QwpColumnBatch())); + Assert.That(state.SymbolDict.Size, Is.EqualTo(0)); + } + + [Test] + public void Decode_RejectsSymbolDictDeltaStartMismatch() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("s", QwpTypeCode.Symbol) } }; + var dict1 = new DeltaSymbolDict { DeltaStart = 0, Entries = { "alpha", "beta" } }; + var data1 = new ResultBatchData { RowCount = 1, Columns = { new SymbolColumnData { DenseDictIds = new[] { 0 } } } }; + var batch1 = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data1, dict1); + + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + decoder.Decode(PayloadOf(batch1).Span, HeaderFlagsOf(batch1), new QwpColumnBatch()); + Assert.That(state.SymbolDict.Size, Is.EqualTo(2)); + + // Second batch with deltaStart=0 but cursor is 2 → must throw. + var dict2 = new DeltaSymbolDict { DeltaStart = 0, Entries = { "gamma" } }; + var data2 = new ResultBatchData { RowCount = 1, Columns = { new SymbolColumnData { DenseDictIds = new[] { 0 } } } }; + var batch2 = QwpEgressFrameBuilder.BuildResultBatch(1L, 1L, schema, data2, dict2); + + Assert.Throws(() => + decoder.Decode(PayloadOf(batch2).Span, HeaderFlagsOf(batch2), new QwpColumnBatch())); + } + + [Test] + public void Decode_OverlongVarint_ThrowsQwpDecodeExceptionNotIngressError() + { + // Regression (M3): a malformed varint reaches the shared QwpVarint.Read, which classifies it + // as the retryable ProtocolVersionError. Inside a RESULT_BATCH that is terminal frame + // corruption — the decoder must surface it as QwpDecodeException so the query loop maps it to + // ProtocolViolation rather than reconnecting against a deterministically-corrupt frame. + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); // request_id + p.AddRange(Enumerable.Repeat((byte)0x80, 11)); // batch_seq varint: 11 continuation bytes (overlong) + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("varint", ex!.Message); + } + + [Test] + public void Decode_GorillaTimestampBadFlag_ThrowsQwpDecodeExceptionNotIngressError() + { + // Regression (M3): QwpGorilla.DecodeToBytes flags an unknown encoding byte as the retryable + // ProtocolVersionError. Inside a RESULT_BATCH it must surface as QwpDecodeException. + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); // request_id + p.Add(0x00); // batch_seq = 0 + p.Add(0x00); // empty table name + p.Add(0x01); // row_count = 1 + p.Add(0x01); // col_count = 1 + p.Add(0x02); p.Add((byte)'t'); p.Add((byte)'s'); + p.Add((byte)QwpTypeCode.Timestamp); + p.Add(0x00); // null_flag = 0 → nonNull = 1 + p.Add(0x05); // Gorilla encoding flag: neither 0x00 nor 0x01 + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => + decoder.Decode(p.ToArray(), QwpConstants.FlagGorilla, new QwpColumnBatch())); + StringAssert.Contains("Gorilla", ex!.Message); + } + + [Test] + public void Decode_GorillaTimestampTruncatedBitstream_ThrowsQwpDecodeExceptionNotInvalidOperation() + { + // Regression (M3): a truncated Gorilla DoD bitstream makes QwpBitReader throw + // InvalidOperationException ("bit reader exhausted"). That, too, is terminal RESULT_BATCH + // corruption and must surface as QwpDecodeException, not leak the raw runtime exception. + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); // request_id + p.Add(0x00); // batch_seq = 0 + p.Add(0x00); // empty table name + p.Add(0x03); // row_count = 3 + p.Add(0x01); // col_count = 1 + p.Add(0x02); p.Add((byte)'t'); p.Add((byte)'s'); + p.Add((byte)QwpTypeCode.Timestamp); + p.Add(0x00); // null_flag = 0 → nonNull = 3 + p.Add(QwpGorilla.EncodingGorilla); // 0x01 + p.AddRange(new byte[8]); // first timestamp + p.AddRange(new byte[8]); // second timestamp + // no DoD bytes for the 3rd value → QwpBitReader runs off the end + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => + decoder.Decode(p.ToArray(), QwpConstants.FlagGorilla, new QwpColumnBatch())); + StringAssert.Contains("Gorilla", ex!.Message); + } + + private static void WriteVarintTo(List dst, ulong v) + { + while (v >= 0x80) + { + dst.Add((byte)(v | 0x80)); + v >>= 7; + } + dst.Add((byte)v); + } + + private static void WriteI32(List dst, int v) + { + dst.Add((byte)v); + dst.Add((byte)(v >> 8)); + dst.Add((byte)(v >> 16)); + dst.Add((byte)(v >> 24)); + } + + private static byte HeaderFlagsOf(byte[] frame) => frame[QwpConstants.OffsetFlags]; + + private static ReadOnlyMemory PayloadOf(byte[] frame) + { + var len = (int)BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(QwpConstants.OffsetPayloadLength, 4)); + return frame.AsMemory(QwpConstants.HeaderSize, len); + } + + private static byte[] LongsLe(params long[] values) + { + var bytes = new byte[values.Length * 8]; + for (var i = 0; i < values.Length; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(i * 8, 8), values[i]); + } + return bytes; + } + + private static byte[] IntsLe(params int[] values) + { + var bytes = new byte[values.Length * 4]; + for (var i = 0; i < values.Length; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(i * 4, 4), values[i]); + } + return bytes; + } + + [Test] + public void Decode_FloatColumn_RoundTrips() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("f", QwpTypeCode.Float) } }; + var bytes = new byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(0, 4), BitConverter.SingleToInt32Bits(3.14f)); + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(4, 4), BitConverter.SingleToInt32Bits(-2.5f)); + var data = new ResultBatchData { RowCount = 2, Columns = { new FixedColumnData { DenseBytes = bytes } } }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetFloatValue(0, 0), Is.EqualTo(3.14f)); + Assert.That(batch.GetFloatValue(0, 1), Is.EqualTo(-2.5f)); + } + + [Test] + public void Decode_DoubleColumn_RoundTrips() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Double) } }; + var bytes = new byte[16]; + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(0, 8), BitConverter.DoubleToInt64Bits(1.234)); + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(8, 8), BitConverter.DoubleToInt64Bits(-9.87e10)); + var data = new ResultBatchData { RowCount = 2, Columns = { new FixedColumnData { DenseBytes = bytes } } }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDoubleValue(0, 0), Is.EqualTo(1.234)); + Assert.That(batch.GetDoubleValue(0, 1), Is.EqualTo(-9.87e10)); + } + + [Test] + public void Decode_DateColumn_RoundTrips() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Date) } }; + var values = new[] { 1_700_000_000_000L, 1_700_000_000_500L, 1_700_000_001_000L }; + var data = new ResultBatchData + { + RowCount = values.Length, + Columns = { new TimestampColumnData { DenseValues = values } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + for (var i = 0; i < values.Length; i++) + { + Assert.That(batch.GetDateValue(0, i), Is.EqualTo(values[i])); + } + } + + [Test] + public void Decode_TimestampNanosColumn_RoundTrips() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("ts", QwpTypeCode.TimestampNanos) } }; + var values = new[] + { + 1_700_000_000_000_000_000L, + 1_700_000_000_000_000_500L, + 1_700_000_000_000_001_000L, + }; + var data = new ResultBatchData + { + RowCount = values.Length, + Columns = { new TimestampColumnData { DenseValues = values } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + for (var i = 0; i < values.Length; i++) + { + Assert.That(batch.GetTimestampValue(0, i), Is.EqualTo(values[i])); + } + } + + [Test] + public void Decode_ByteShortCharColumn_RoundTrips() + { + var schema = new ResultSchema + { + Columns = + { + new SchemaColumn("b", QwpTypeCode.Byte), + new SchemaColumn("s", QwpTypeCode.Short), + new SchemaColumn("c", QwpTypeCode.Char), + }, + }; + var shorts = new byte[4]; + BinaryPrimitives.WriteInt16LittleEndian(shorts.AsSpan(0, 2), 1234); + BinaryPrimitives.WriteInt16LittleEndian(shorts.AsSpan(2, 2), -5678); + var chars = new byte[4]; + BinaryPrimitives.WriteUInt16LittleEndian(chars.AsSpan(0, 2), 'Z'); + BinaryPrimitives.WriteUInt16LittleEndian(chars.AsSpan(2, 2), '中'); + var data = new ResultBatchData + { + RowCount = 2, + Columns = + { + new FixedColumnData { DenseBytes = new byte[] { 0xAA, 0x55 } }, + new FixedColumnData { DenseBytes = shorts }, + new FixedColumnData { DenseBytes = chars }, + }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetByteValue(0, 0), Is.EqualTo((byte)0xAA)); + Assert.That(batch.GetByteValue(0, 1), Is.EqualTo((byte)0x55)); + Assert.That(batch.GetShortValue(1, 0), Is.EqualTo((short)1234)); + Assert.That(batch.GetShortValue(1, 1), Is.EqualTo((short)-5678)); + Assert.That(batch.GetCharValue(2, 0), Is.EqualTo('Z')); + Assert.That(batch.GetCharValue(2, 1), Is.EqualTo('中')); + } + + [Test] + public void Decode_Long256Column_RoundTrips() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("l", QwpTypeCode.Long256) } }; + var dense = LongsLe(0x1111111111111111L, 0x2222222222222222L, + 0x3333333333333333L, 0x4444444444444444L); + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = dense } } }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetColumnWireType(0), Is.EqualTo(QwpTypeCode.Long256)); + batch.GetLong256(0, 0, out var w0, out var w1, out var w2, out var w3); + Assert.That(w0, Is.EqualTo(0x1111111111111111L)); + Assert.That(w1, Is.EqualTo(0x2222222222222222L)); + Assert.That(w2, Is.EqualTo(0x3333333333333333L)); + Assert.That(w3, Is.EqualTo(0x4444444444444444L)); + } + + [Test] + public void Decode_Long256Column_NullRow_AllZero() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("l", QwpTypeCode.Long256) } }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { NullBitmap = new byte[] { 0b01 }, DenseBytes = Array.Empty() } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.IsNull(0, 0), Is.True); + batch.GetLong256(0, 0, out var w0, out var w1, out var w2, out var w3); + Assert.That(w0 | w1 | w2 | w3, Is.EqualTo(0L)); + } + + [Test] + public void Decode_Decimal128Column_CarriesScale() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Decimal128) } }; + var dense = new byte[16]; + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(0, 8), 0x0123456789ABCDEFL); + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(8, 8), 0x0L); + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 6, DenseBytes = dense } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDecimalScale(0), Is.EqualTo((byte)6)); + Assert.That(batch.GetDecimal128Lo(0, 0), Is.EqualTo(0x0123456789ABCDEFL)); + Assert.That(batch.GetDecimal128Hi(0, 0), Is.EqualTo(0L)); + } + + [Test] + public void Decode_Decimal128Column_TwoRows_ReadsLoHiPerRow() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Decimal128) } }; + var dense = new byte[32]; + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(0, 8), 0x1111111111111111L); + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(8, 8), 0x2222222222222222L); + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(16, 8), 0x3333333333333333L); + BinaryPrimitives.WriteInt64LittleEndian(dense.AsSpan(24, 8), 0x4444444444444444L); + var data = new ResultBatchData + { + RowCount = 2, + Columns = { new DecimalColumnData { Scale = 0, DenseBytes = dense } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDecimal128Lo(0, 0), Is.EqualTo(0x1111111111111111L)); + Assert.That(batch.GetDecimal128Hi(0, 0), Is.EqualTo(0x2222222222222222L)); + Assert.That(batch.GetDecimal128Lo(0, 1), Is.EqualTo(0x3333333333333333L)); + Assert.That(batch.GetDecimal128Hi(0, 1), Is.EqualTo(0x4444444444444444L)); + } + + [Test] + public void Decode_Decimal256Column_CarriesScale() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Decimal256) } }; + var dense = LongsLe(1L, 2L, 3L, 4L); + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 30, DenseBytes = dense } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDecimalScale(0), Is.EqualTo((byte)30)); + batch.GetDecimal256(0, 0, out var ll, out var lh, out var hl, out var hh); + Assert.That(ll, Is.EqualTo(1L)); + Assert.That(lh, Is.EqualTo(2L)); + Assert.That(hl, Is.EqualTo(3L)); + Assert.That(hh, Is.EqualTo(4L)); + } + + [Test] + public void Decode_LongNullSentinel_VisibleAsIsNullAndZeroValue() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("v", QwpTypeCode.Long) } }; + // bitmap byte 0b01: row 0 null, row 1 non-null + var data = new ResultBatchData + { + RowCount = 2, + Columns = { new FixedColumnData { NullBitmap = new byte[] { 0b01 }, DenseBytes = LongsLe(99L) } }, + }; + + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.IsNull(0, 0), Is.True); + Assert.That(batch.IsNull(0, 1), Is.False); + Assert.That(batch.GetLongValue(0, 0), Is.EqualTo(0L)); + Assert.That(batch.GetLongValue(0, 1), Is.EqualTo(99L)); + } + + [Test] + public void Decode_RejectsTrailingBytesAfterTableBlock() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("a", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(1L) } } }; + var frame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + + var payload = PayloadOf(frame).ToArray(); + var grown = new byte[payload.Length + 3]; + payload.CopyTo(grown, 0); + grown[^1] = 0xFE; + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(grown, HeaderFlagsOf(frame), new QwpColumnBatch())); + StringAssert.Contains("trailing bytes", ex!.Message); + } + + [Test] + public void Decode_EmptyResultSet_RowCountZero_Succeeds() + { + var schema = new ResultSchema + { + Columns = { new SchemaColumn("id", QwpTypeCode.Long), new SchemaColumn("name", QwpTypeCode.Varchar) }, + }; + var data = new ResultBatchData + { + RowCount = 0, + Columns = { new FixedColumnData { DenseBytes = Array.Empty() }, new VarcharColumnData { DenseValues = Array.Empty() } }, + }; + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.RowCount, Is.EqualTo(0)); + Assert.That(batch.ColumnCount, Is.EqualTo(2)); + Assert.That(batch.GetColumnName(0), Is.EqualTo("id")); + Assert.That(batch.GetColumnName(1), Is.EqualTo("name")); + } + + [Test] + public void Decode_Decimal128_HighScale_DecodesAsByteValue() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Decimal128) } }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 200, DenseBytes = new byte[16] } }, + }; + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDecimalScale(0), Is.EqualTo((byte)200)); + } + + [Test] + public void Decode_Decimal64_HighScale_DecodesAsByteValue() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Decimal64) } }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 25, DenseBytes = new byte[8] } }, + }; + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDecimalScale(0), Is.EqualTo((byte)25)); + } + + [Test] + public void Decode_Decimal256_MaxScale_DecodesAsByteValue() + { + var schema = new ResultSchema { Columns = { new SchemaColumn("d", QwpTypeCode.Decimal256) } }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 255, DenseBytes = new byte[32] } }, + }; + var (_, batch, _, _) = DecodeOneBatch(schema, data); + Assert.That(batch.GetDecimalScale(0), Is.EqualTo(byte.MaxValue)); + } + + [Test] + public void Decode_FailedBatch_RewindsSymbolDict() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + // Seed batch_seq=0 with no symbols. + var schemaA = new ResultSchema { Columns = { new SchemaColumn("a", QwpTypeCode.Long) } }; + var frameA = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schemaA, new ResultBatchData + { + RowCount = 1, + Columns = { new FixedColumnData { DenseBytes = LongsLe(42L) } }, + }); + decoder.Decode(PayloadOf(frameA).Span, HeaderFlagsOf(frameA), new QwpColumnBatch()); + Assert.That(state.SymbolDict.Size, Is.EqualTo(0)); + + // Hand-craft a malformed frame that grows the symbol dict in its prelude, then truncates + // the table body so decoding fails. The decoder must roll the dict back to size 0. + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); // request_id + p.Add(0x01); // batch_seq = 1 (continuation) + // Symbol dict prelude: deltaStart=0, deltaCount=1, entry "newSym" + p.Add(0x00); + p.Add(0x01); + p.Add((byte)"newSym".Length); + p.AddRange(Encoding.UTF8.GetBytes("newSym")); + // Table block (continuation, so just name + row_count, then truncate) + p.Add(0x00); // empty table name + p.Add(0x01); // row_count = 1 + // Missing column data → decoder throws + + Assert.Throws(() => + decoder.Decode(p.ToArray(), QwpConstants.FlagDeltaSymbolDict, new QwpColumnBatch())); + Assert.That(state.SymbolDict.Size, Is.EqualTo(0), + "symbol dict must rewind to pre-batch cursor when decoding fails mid-frame"); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Query/QwpRoleFilterTests.cs b/src/net-questdb-client-tests/Qwp/Query/QwpRoleFilterTests.cs new file mode 100644 index 00000000..e3557f17 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpRoleFilterTests.cs @@ -0,0 +1,100 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Query; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QwpRoleFilterTests +{ + [TestCase(QwpRole.Standalone)] + [TestCase(QwpRole.Primary)] + [TestCase(QwpRole.Replica)] + [TestCase(QwpRole.PrimaryCatchup)] + public void Any_AcceptsSpecDefinedRoleBytes(QwpRole role) + { + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.any), Is.True); + } + + [TestCase((QwpRole)0x04)] + [TestCase((QwpRole)0x7F)] + [TestCase((QwpRole)0xFF)] + public void Any_RejectsUnknownRoleByte_SoFutureServerBugsAreLoud(QwpRole role) + { + // target=any silently accepting any byte was masking buggy or future-protocol servers + // as "matches anything"; only the four defined roles (0x00..0x03) are allowed. + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.any), Is.False); + } + + [TestCase(QwpRole.Standalone, true)] + [TestCase(QwpRole.Primary, true)] + [TestCase(QwpRole.PrimaryCatchup, true)] + [TestCase(QwpRole.Replica, false)] + public void Primary_AcceptsStandalonePrimaryAndCatchup(QwpRole role, bool expected) + { + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.primary), Is.EqualTo(expected)); + } + + [TestCase(QwpRole.Standalone, false)] + [TestCase(QwpRole.Primary, false)] + [TestCase(QwpRole.PrimaryCatchup, false)] + [TestCase(QwpRole.Replica, true)] + public void Replica_AcceptsReplicaOnly(QwpRole role, bool expected) + { + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.replica), Is.EqualTo(expected)); + } + + [TestCase("PRIMARY", QwpRole.Primary)] + [TestCase("primary", QwpRole.Primary)] + [TestCase("Primary", QwpRole.Primary)] + [TestCase("REPLICA", QwpRole.Replica)] + [TestCase("replica", QwpRole.Replica)] + [TestCase("STANDALONE", QwpRole.Standalone)] + [TestCase("standalone", QwpRole.Standalone)] + [TestCase("PRIMARY_CATCHUP", QwpRole.PrimaryCatchup)] + [TestCase("Primary_Catchup", QwpRole.PrimaryCatchup)] + [TestCase("primary_catchup", QwpRole.PrimaryCatchup)] + public void MapRoleName_IsCaseInsensitive(string role, QwpRole expected) + { + // QwpIngressRoleRejectedException carries the X-QuestDB-Role header verbatim; matching + // case-sensitively (the old code) silently dropped well-formed but mixed-case responses + // into the byte.MaxValue diagnostic-only bucket. + Assert.That(QwpQueryWebSocketClient.MapRoleName(role), Is.EqualTo(expected)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("UNKNOWN")] + public void MapRoleName_UnknownStringReturnsSentinel(string? role) + { + Assert.That(QwpQueryWebSocketClient.MapRoleName(role), Is.EqualTo(QwpRole.Undefined)); + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/QwpBitWriterTests.cs b/src/net-questdb-client-tests/Qwp/QwpBitWriterTests.cs new file mode 100644 index 00000000..503c236b --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpBitWriterTests.cs @@ -0,0 +1,142 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpBitWriterTests +{ + [Test] + public void RoundTrip_ZeroBits_NoState() + { + Span buf = stackalloc byte[8]; + var w = new QwpBitWriter(buf, 0); + w.WriteBits(0, 0); + Assert.That(w.FinishToByteBoundary(), Is.Zero); + } + + [Test] + public void RoundTrip_SingleBit() + { + Span buf = stackalloc byte[1]; + var w = new QwpBitWriter(buf, 0); + w.WriteBits(1, 1); + Assert.That(buf[0], Is.EqualTo((byte)1)); + + var r = new QwpBitReader(buf, 0); + Assert.That(r.ReadBits(1), Is.EqualTo(1UL)); + } + + [Test] + public void RoundTrip_PartialThenWholeBytesThenTail_64Bits() + { + Span buf = stackalloc byte[12]; + var w = new QwpBitWriter(buf, 0); + w.WriteBits(0b101UL, 3); + w.WriteBits(0xFEDCBA9876543210UL, 64); + w.WriteBits(0b1011, 4); + var written = w.FinishToByteBoundary(); + + var r = new QwpBitReader(buf, 0); + Assert.That(r.ReadBits(3), Is.EqualTo(0b101UL)); + Assert.That(r.ReadBits(64), Is.EqualTo(0xFEDCBA9876543210UL)); + Assert.That(r.ReadBits(4), Is.EqualTo(0b1011UL)); + Assert.That(written, Is.EqualTo((3 + 64 + 4 + 7) / 8)); + } + + [Test] + public void WriteBits_HigherBitsTruncated() + { + Span buf = stackalloc byte[1]; + var w = new QwpBitWriter(buf, 0); + w.WriteBits(0xFFUL, 4); + + var r = new QwpBitReader(buf, 0); + Assert.That(r.ReadBits(4), Is.EqualTo(0x0FUL)); + } + + [Test] + public void WriteBits_ExhaustedBuffer_Throws() + { + var buf = new byte[1]; + var w = new QwpBitWriter(buf, 0); + w.WriteBits(0, 8); + + InvalidOperationException? thrown = null; + try { w.WriteBits(0, 1); } + catch (InvalidOperationException ex) { thrown = ex; } + Assert.That(thrown, Is.Not.Null); + } + + [Test] + public void RoundTrip_FuzzedRandomWidths() + { + var rnd = new Random(0xBEEF); + for (var trial = 0; trial < 200; trial++) + { + var widths = new int[rnd.Next(1, 64)]; + var values = new ulong[widths.Length]; + var totalBits = 0; + for (var i = 0; i < widths.Length; i++) + { + var w = rnd.Next(1, 65); + widths[i] = w; + var v = ((ulong)rnd.NextInt64() << 32) ^ (uint)rnd.Next(); + values[i] = w == 64 ? v : v & ((1UL << w) - 1UL); + totalBits += w; + } + + var buf = new byte[(totalBits + 7) / 8 + 1]; + var writer = new QwpBitWriter(buf, 0); + for (var i = 0; i < widths.Length; i++) + { + writer.WriteBits(values[i], widths[i]); + } + + var reader = new QwpBitReader(buf, 0); + for (var i = 0; i < widths.Length; i++) + { + Assert.That(reader.ReadBits(widths[i]), Is.EqualTo(values[i]), + $"trial={trial} idx={i} width={widths[i]}"); + } + } + } + + [Test] + public void WriteBits_NonZeroStartOffset_PreservesPreceding() + { + Span buf = stackalloc byte[4]; + buf[0] = 0xAA; + var w = new QwpBitWriter(buf, 1); + w.WriteBits(0xBEEFUL, 16); + + Assert.That(buf[0], Is.EqualTo((byte)0xAA), "offset preceding the writer must be untouched"); + + var r = new QwpBitReader(buf, 1); + Assert.That(r.ReadBits(16), Is.EqualTo(0xBEEFUL)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpColumnExtendedTypesTests.cs b/src/net-questdb-client-tests/Qwp/QwpColumnExtendedTypesTests.cs new file mode 100644 index 00000000..8e1f3dec --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpColumnExtendedTypesTests.cs @@ -0,0 +1,676 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Numerics; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpColumnExtendedTypesTests +{ + [Test] + public void AppendDecimal128_PositiveValue_WritesUnscaledLittleEndian() + { + // 12.34m has unscaled = 1234, scale = 2. + var col = new QwpColumn("price", 0); + col.AppendDecimal128(12.34m); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Decimal128)); + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + Assert.That(col.DecimalScaleSet, Is.True); + Assert.That(col.FixedLen, Is.EqualTo(16)); + + var unscaled = ReadInt128(col.FixedData!.AsSpan(0, 16)); + Assert.That(unscaled, Is.EqualTo(new BigInteger(1234))); + } + + [Test] + public void AppendDecimal128_NegativeValue_IsTwosComplement() + { + var col = new QwpColumn("delta", 0); + col.AppendDecimal128(-1.5m); + + Assert.That(col.DecimalScale, Is.EqualTo((byte)1)); + + var unscaled = ReadInt128(col.FixedData!.AsSpan(0, 16)); + Assert.That(unscaled, Is.EqualTo(new BigInteger(-15))); + } + + [Test] + public void AppendDecimal128_Zero_WritesAllZeroes() + { + var col = new QwpColumn("z", 0); + col.AppendDecimal128(0m); + + Assert.That(col.DecimalScale, Is.EqualTo((byte)0)); + var bytes = col.FixedData!.AsSpan(0, 16).ToArray(); + Assert.That(bytes, Is.EqualTo(new byte[16])); + } + + [Test] + public void AppendDecimal128_ScaleMismatch_Throws() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal128(1.5m); // scale 1 + Assert.Throws(() => col.AppendDecimal128(2.05m)); // scale 2 + } + + [Test] + public void AppendDecimal128_LargePositiveValue_RoundTrips() + { + // .NET decimal max ≈ 7.9e28, scale 0. + var col = new QwpColumn("big", 0); + col.AppendDecimal128(decimal.MaxValue); + + var unscaled = ReadInt128(col.FixedData!.AsSpan(0, 16)); + // decimal.MaxValue = 79228162514264337593543950335 = 2^96 - 1. + Assert.That(unscaled, Is.EqualTo((BigInteger.One << 96) - 1)); + } + + [Test] + public void AppendDecimal128_LargeNegativeValue_RoundTrips() + { + var col = new QwpColumn("big", 0); + col.AppendDecimal128(decimal.MinValue); + + var unscaled = ReadInt128(col.FixedData!.AsSpan(0, 16)); + Assert.That(unscaled, Is.EqualTo(-((BigInteger.One << 96) - 1))); + } + + [Test] + public void AppendLong256_SmallValue_PadsTo32Bytes() + { + var col = new QwpColumn("hash", 0); + col.AppendLong256(new BigInteger(0x123456789ABCDEF0)); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Long256)); + Assert.That(col.FixedLen, Is.EqualTo(32)); + + var span = col.FixedData!.AsSpan(0, 32); + // Low 8 bytes: LE encoding of 0x123456789ABCDEF0 = F0 DE BC 9A 78 56 34 12. + Assert.That(BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(0, 8)), + Is.EqualTo(0x123456789ABCDEF0UL)); + // Upper 24 bytes are zero. + for (var i = 8; i < 32; i++) + { + Assert.That(span[i], Is.Zero); + } + } + + [Test] + public void AppendLong256_MaxValue_FillsAll32Bytes() + { + var col = new QwpColumn("hash", 0); + var max = (BigInteger.One << 256) - 1; + col.AppendLong256(max); + + var span = col.FixedData!.AsSpan(0, 32); + for (var i = 0; i < 32; i++) + { + Assert.That(span[i], Is.EqualTo((byte)0xFF), $"byte {i}"); + } + } + + [Test] + public void AppendLong256_NegativeOne_EncodesAsAll0xFF() + { + var col = new QwpColumn("hash", 0); + col.AppendLong256(new BigInteger(-1)); + + var span = col.FixedData!.AsSpan(0, 32); + for (var i = 0; i < 32; i++) + { + Assert.That(span[i], Is.EqualTo((byte)0xFF), $"byte {i}"); + } + } + + [Test] + public void AppendLong256_MinSignedInt256_EncodesAsHighBitOnly() + { + var col = new QwpColumn("hash", 0); + var minSigned = -(BigInteger.One << 255); + col.AppendLong256(minSigned); + + var span = col.FixedData!.AsSpan(0, 32); + for (var i = 0; i < 31; i++) + { + Assert.That(span[i], Is.Zero, $"byte {i}"); + } + Assert.That(span[31], Is.EqualTo((byte)0x80)); + } + + [Test] + public void AppendLong256_BelowSignedMinimum_Throws() + { + var col = new QwpColumn("hash", 0); + var underflow = -(BigInteger.One << 255) - 1; + Assert.Throws(() => col.AppendLong256(underflow)); + } + + [Test] + public void AppendLong256_AboveUnsignedMaximum_Throws() + { + var col = new QwpColumn("hash", 0); + var tooBig = BigInteger.One << 256; + Assert.Throws(() => col.AppendLong256(tooBig)); + } + + [Test] + public void AppendLong256_FourLongs_WritesLittleEndianWords() + { + var col = new QwpColumn("hash", 0); + col.AppendLong256(0x0807060504030201L, 0x100F0E0D0C0B0A09L, 0x1817161514131211L, 0x201F1E1D1C1B1A19L); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Long256)); + Assert.That(col.FixedLen, Is.EqualTo(32)); + + var span = col.FixedData!.AsSpan(0, 32); + for (var i = 0; i < 32; i++) + { + Assert.That(span[i], Is.EqualTo((byte)(i + 1)), $"byte {i}"); + } + } + + [Test] + public void AppendLong256_FourLongs_AllOnes_RoundTripsToBigInteger() + { + var colA = new QwpColumn("a", 0); + var colB = new QwpColumn("b", 0); + colA.AppendLong256(-1L, -1L, -1L, -1L); + colB.AppendLong256((BigInteger.One << 256) - 1); + Assert.That(colA.FixedData!.AsSpan(0, 32).ToArray(), + Is.EqualTo(colB.FixedData!.AsSpan(0, 32).ToArray())); + } + + [Test] + public void AppendLong256_StaleBytesAreCleared() + { + // Pre-fill FixedData with garbage to ensure padding actually clears. + var col = new QwpColumn("hash", 0); + col.AppendLong256((BigInteger.One << 256) - 1); + col.Clear(); + + col.AppendLong256(new BigInteger(0x42)); + Assert.That(col.FixedData!.AsSpan(0, 32).ToArray()[1..], Is.All.Zero); + Assert.That(col.FixedData![0], Is.EqualTo((byte)0x42)); + } + + [Test] + public void AppendGeohash_LocksPrecision() + { + var col = new QwpColumn("loc", 0); + col.AppendGeohash(0xABCDEF, 24); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Geohash)); + Assert.That(col.GeohashPrecisionBits, Is.EqualTo(24)); + Assert.That(col.GeohashPrecisionSet, Is.True); + Assert.That(col.FixedLen, Is.EqualTo(3), "ceil(24/8) = 3 bytes"); + + var span = col.FixedData!.AsSpan(0, 3); + Assert.That(span[0], Is.EqualTo(0xEF)); + Assert.That(span[1], Is.EqualTo(0xCD)); + Assert.That(span[2], Is.EqualTo(0xAB)); + } + + [Test] + public void AppendGeohash_PrecisionMismatch_Throws() + { + var col = new QwpColumn("loc", 0); + col.AppendGeohash(1, 24); + Assert.Throws(() => col.AppendGeohash(2, 32)); + } + + [Test] + public void AppendGeohash_OutOfRangePrecision_Throws() + { + var col = new QwpColumn("loc", 0); + Assert.Throws(() => col.AppendGeohash(0, 0)); + + var col2 = new QwpColumn("loc", 0); + Assert.Throws(() => col2.AppendGeohash(0, 61)); + } + + [Test] + public void AppendGeohash_OddPrecisionBits_StillWorks() + { + // 7 bits → ceil(7/8) = 1 byte, low 7 bits of value preserved. + var col = new QwpColumn("loc", 0); + col.AppendGeohash(0b1010101, 7); + + Assert.That(col.FixedLen, Is.EqualTo(1)); + Assert.That(col.FixedData![0], Is.EqualTo(0b1010101)); + } + + [Test] + public void AppendGeohash_60Bits_Uses8Bytes() + { + var col = new QwpColumn("loc", 0); + col.AppendGeohash(0x0FEDCBA987654321UL, 60); + + Assert.That(col.FixedLen, Is.EqualTo(8)); + var hash = BinaryPrimitives.ReadUInt64LittleEndian(col.FixedData!.AsSpan(0, 8)); + Assert.That(hash, Is.EqualTo(0x0FEDCBA987654321UL)); + } + + [Test] + public void AppendGeohash_HighBitsAbovePrecision_AreMasked() + { + // precision=5 means only the low 5 bits matter; the top 3 bits of the byte must be zero. + var col = new QwpColumn("loc", 0); + col.AppendGeohash(0xFFFF_FFFF_FFFF_FFFFUL, 5); + + Assert.That(col.FixedLen, Is.EqualTo(1)); + Assert.That(col.FixedData![0], Is.EqualTo(0b0001_1111)); + } + + [Test] + public void AppendGeohash_PartialByte_IgnoresHighBits() + { + // precision=12 → 2 bytes; bits 12..15 of the second byte must be zero. + var col = new QwpColumn("loc", 0); + col.AppendGeohash(0xFFFFUL, 12); + + Assert.That(col.FixedLen, Is.EqualTo(2)); + Assert.That(col.FixedData![0], Is.EqualTo(0xFF)); + Assert.That(col.FixedData![1], Is.EqualTo(0x0F)); + } + + [Test] + public void AppendDoubleArray_1D_WritesNDimsShapeAndValues() + { + var col = new QwpColumn("vec", 0); + col.AppendDoubleArray(new double[] { 1.0, 2.0, 3.0 }, new[] { 3 }); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.DoubleArray)); + Assert.That(col.FixedLen, Is.EqualTo(1 + 4 + 3 * 8)); + + var span = col.FixedData!.AsSpan(0, col.FixedLen); + Assert.That(span[0], Is.EqualTo((byte)1), "n_dims = 1"); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(span.Slice(1, 4)), Is.EqualTo(3)); + Assert.That(BinaryPrimitives.ReadDoubleLittleEndian(span.Slice(5, 8)), Is.EqualTo(1.0)); + Assert.That(BinaryPrimitives.ReadDoubleLittleEndian(span.Slice(13, 8)), Is.EqualTo(2.0)); + Assert.That(BinaryPrimitives.ReadDoubleLittleEndian(span.Slice(21, 8)), Is.EqualTo(3.0)); + } + + [Test] + public void AppendDoubleArray_2D_RoundTripsShape() + { + var col = new QwpColumn("mat", 0); + // 2x3 matrix + col.AppendDoubleArray(new double[] { 1, 2, 3, 4, 5, 6 }, new[] { 2, 3 }); + + var span = col.FixedData!.AsSpan(0, col.FixedLen); + Assert.That(span[0], Is.EqualTo((byte)2)); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(span.Slice(1, 4)), Is.EqualTo(2)); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(span.Slice(5, 4)), Is.EqualTo(3)); + // 6 values × 8 bytes = 48 bytes after 9-byte header. + Assert.That(col.FixedLen, Is.EqualTo(9 + 48)); + } + + [Test] + public void AppendDoubleArray_ShapeMismatch_Throws() + { + var col = new QwpColumn("v", 0); + Assert.Throws(() => + col.AppendDoubleArray(new double[] { 1, 2, 3 }, new[] { 2 })); // shape product=2 but 3 values + } + + [Test] + public void AppendDoubleArray_TooManyDimensions_Throws() + { + var col = new QwpColumn("v", 0); + var shape = new int[QwpConstants.MaxArrayDimensions + 1]; + Array.Fill(shape, 1); + Assert.Throws(() => col.AppendDoubleArray(new double[1], shape)); + } + + [Test] + public void AppendDoubleArray_EmptyShape_Throws() + { + var col = new QwpColumn("v", 0); + Assert.Throws(() => + col.AppendDoubleArray(ReadOnlySpan.Empty, ReadOnlySpan.Empty)); + } + + [Test] + public void AppendDoubleArray_ZeroLengthDimension_IsAllowed() + { + var col = new QwpColumn("v", 0); + col.AppendDoubleArray(ReadOnlySpan.Empty, new[] { 0 }); + + Assert.That(col.NonNullCount, Is.EqualTo(1)); + // 1 byte n_dims + 4 bytes dim_len(0) + 0 bytes values = 5 bytes. + Assert.That(col.FixedLen, Is.EqualTo(5)); + } + + [Test] + public void AppendDoubleArray_MultipleRows_PackBackToBack() + { + var col = new QwpColumn("v", 0); + col.AppendDoubleArray(new double[] { 1.0 }, new[] { 1 }); + col.AppendDoubleArray(new double[] { 2.0, 3.0 }, new[] { 2 }); + + // First row: 5 + 8 = 13 bytes; second row: 5 + 16 = 21 bytes; total 34. + Assert.That(col.FixedLen, Is.EqualTo(13 + 21)); + Assert.That(col.NonNullCount, Is.EqualTo(2)); + } + + [Test] + public void AppendLongArray_1D_WritesValues() + { + var col = new QwpColumn("v", 0); + col.AppendLongArray(new long[] { 100, -200, 300 }, new[] { 3 }); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.LongArray)); + var span = col.FixedData!.AsSpan(0, col.FixedLen); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(5, 8)), Is.EqualTo(100L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(13, 8)), Is.EqualTo(-200L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(21, 8)), Is.EqualTo(300L)); + } + + [Test] + public void Clear_PreservesDecimalScaleAndGeohashPrecision() + { + var col = new QwpColumn("c", 0); + col.AppendDecimal128(1.5m); + Assert.That(col.DecimalScaleSet, Is.True); + Assert.That(col.DecimalScale, Is.EqualTo((byte)1)); + col.Clear(); + Assert.That(col.DecimalScaleSet, Is.True); + Assert.That(col.DecimalScale, Is.EqualTo((byte)1)); + + col.AppendDecimal128(2.5m); + Assert.That(col.DecimalScale, Is.EqualTo((byte)1)); + } + + [Test] + public void AppendDecimal128_LosslessRescaleUp_PadsTrailingZeros() + { + var col = new QwpColumn("c", 0); + col.AppendDecimal128(1.50m); // scale 2 → locks + col.AppendDecimal128(1.5m); // scale 1 → must rescale up to 2 + + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + Assert.That(col.NonNullCount, Is.EqualTo(2)); + var first = ReadInt128(col.FixedData!.AsSpan(0, 16)); + var second = ReadInt128(col.FixedData!.AsSpan(16, 16)); + Assert.That(first, Is.EqualTo(new BigInteger(150))); + Assert.That(second, Is.EqualTo(new BigInteger(150))); + } + + [Test] + public void AppendDecimal128_LosslessRescaleDown_DropsTrailingZeros() + { + var col = new QwpColumn("c", 0); + col.AppendDecimal128(1.5m); // scale 1 → locks + col.AppendDecimal128(1.50m); // scale 2 with trailing zero → rescale down to 1 + + Assert.That(col.DecimalScale, Is.EqualTo((byte)1)); + var first = ReadInt128(col.FixedData!.AsSpan(0, 16)); + var second = ReadInt128(col.FixedData!.AsSpan(16, 16)); + Assert.That(first, Is.EqualTo(new BigInteger(15))); + Assert.That(second, Is.EqualTo(new BigInteger(15))); + } + + [Test] + public void AppendDecimal128_RescaleDownLossy_Throws() + { + var col = new QwpColumn("c", 0); + col.AppendDecimal128(1.5m); + var ex = Assert.Throws(() => col.AppendDecimal128(1.55m)); + Assert.That(ex!.Message, Does.Contain("precision loss")); + } + + [Test] + public void AppendDecimal128_NegativeRescale_PreservesValue() + { + var col = new QwpColumn("c", 0); + col.AppendDecimal128(-1.50m); + col.AppendDecimal128(-1.5m); + + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + var first = ReadInt128(col.FixedData!.AsSpan(0, 16)); + var second = ReadInt128(col.FixedData!.AsSpan(16, 16)); + Assert.That(first, Is.EqualTo(new BigInteger(-150))); + Assert.That(second, Is.EqualTo(new BigInteger(-150))); + } + + [Test] + public void AppendVarchar_LoneSurrogate_ThrowsStrictUtf8() + { + var col = new QwpColumn("c", 0); + Assert.Throws(() => col.AppendVarchar("\uD800")); + } + + [Test] + public void AppendDecimal64_PositiveValue_WritesUnscaledLittleEndian() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal64(12.34m); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Decimal64)); + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + Assert.That(col.FixedLen, Is.EqualTo(8)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(col.FixedData!.AsSpan(0, 8)), Is.EqualTo(1234L)); + } + + [Test] + public void AppendDecimal64_NegativeValue_IsTwosComplement() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal64(-1.5m); + + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(col.FixedData!.AsSpan(0, 8)), Is.EqualTo(-15L)); + } + + [Test] + public void AppendDecimal64_OverflowsRange_Throws() + { + var col = new QwpColumn("p", 0); + // decimal.MaxValue ~ 7.9e28; 8-byte signed mantissa cap is 2^63-1 ~ 9.2e18. + Assert.Throws(() => col.AppendDecimal64(decimal.MaxValue)); + } + + [Test] + public void AppendDecimal256_PositiveValue_WritesUnscaledLittleEndian() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal256(12.34m); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Decimal256)); + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + Assert.That(col.FixedLen, Is.EqualTo(32)); + + var unscaled = new BigInteger(col.FixedData!.AsSpan(0, 32), isUnsigned: false, isBigEndian: false); + Assert.That(unscaled, Is.EqualTo(new BigInteger(1234))); + } + + [Test] + public void AppendDecimal256_NegativeValue_PadsWithFF() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal256(-1m); + + var span = col.FixedData!.AsSpan(0, 32); + Assert.That(span[0], Is.EqualTo((byte)0xFF)); + for (var i = 1; i < 32; i++) + { + Assert.That(span[i], Is.EqualTo((byte)0xFF), $"byte {i}"); + } + } + + [Test] + public void AppendDecimal256_LargestDecimalValue_FitsExactly() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal256(decimal.MaxValue); + + var unscaled = new BigInteger(col.FixedData!.AsSpan(0, 32), isUnsigned: false, isBigEndian: false); + Assert.That(unscaled, Is.EqualTo((BigInteger.One << 96) - 1)); + } + + [Test] + public void AppendBinary_StoresBytesAndOffsets() + { + var col = new QwpColumn("blob", 0); + col.AppendBinary(new byte[] { 0x00, 0x01, 0x02 }); + col.AppendBinary(ReadOnlySpan.Empty); + col.AppendBinary(new byte[] { 0xFF }); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Binary)); + Assert.That(col.NonNullCount, Is.EqualTo(3)); + Assert.That(col.StrLen, Is.EqualTo(4)); + Assert.That(col.StrOffsets![0], Is.EqualTo(0u)); + Assert.That(col.StrOffsets![1], Is.EqualTo(3u)); + Assert.That(col.StrOffsets![2], Is.EqualTo(3u)); + Assert.That(col.StrOffsets![3], Is.EqualTo(4u)); + Assert.That(col.StrData!.AsSpan(0, 4).ToArray(), Is.EqualTo(new byte[] { 0x00, 0x01, 0x02, 0xFF })); + } + + [Test] + public void AppendBinary_TypeMismatch_Throws() + { + var col = new QwpColumn("blob", 0); + col.AppendVarchar("hello"); + Assert.Throws(() => col.AppendBinary(new byte[] { 0x00 })); + } + + [Test] + public void AppendIPv4_WritesFourBytesLittleEndian() + { + var col = new QwpColumn("ip", 0); + col.AppendIPv4(0x04030201u); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.IPv4)); + Assert.That(col.FixedLen, Is.EqualTo(4)); + Assert.That(col.FixedData!.AsSpan(0, 4).ToArray(), Is.EqualTo(new byte[] { 0x01, 0x02, 0x03, 0x04 })); + } + + [Test] + public void AppendIPv4_TypeMismatch_Throws() + { + var col = new QwpColumn("ip", 0); + col.AppendInt(42); + Assert.Throws(() => col.AppendIPv4(1)); + } + + [Test] + public void AppendDecimal64_Limbs_WritesEightBytesLittleEndian() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal64(unchecked((long)0x123456789ABCDEF0UL), scale: 5); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Decimal64)); + Assert.That(col.DecimalScale, Is.EqualTo((byte)5)); + Assert.That(col.DecimalScaleSet, Is.True); + Assert.That(col.FixedLen, Is.EqualTo(8)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(col.FixedData!.AsSpan(0, 8)), + Is.EqualTo(unchecked((long)0x123456789ABCDEF0UL))); + } + + [Test] + public void AppendDecimal128_Limbs_WritesTwoLimbsLsbFirst() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal128(lo: unchecked((long)0xAAAAAAAAAAAAAAAAUL), hi: 0x5555555555555555L, scale: 0); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Decimal128)); + Assert.That(col.FixedLen, Is.EqualTo(16)); + var span = col.FixedData!.AsSpan(0, 16); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(0, 8)), + Is.EqualTo(unchecked((long)0xAAAAAAAAAAAAAAAAUL))); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(8, 8)), + Is.EqualTo(0x5555555555555555L)); + } + + [Test] + public void AppendDecimal256_Limbs_WritesFourLimbsLsbFirst() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal256( + l0: 0x0102030405060708L, + l1: 0x1112131415161718L, + l2: 0x2122232425262728L, + l3: 0x3132333435363738L, + scale: 10); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Decimal256)); + Assert.That(col.DecimalScale, Is.EqualTo((byte)10)); + Assert.That(col.FixedLen, Is.EqualTo(32)); + var span = col.FixedData!.AsSpan(0, 32); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(0, 8)), Is.EqualTo(0x0102030405060708L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(8, 8)), Is.EqualTo(0x1112131415161718L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(16, 8)), Is.EqualTo(0x2122232425262728L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(24, 8)), Is.EqualTo(0x3132333435363738L)); + } + + [Test] + public void AppendDecimal64_Limbs_LosslessRescale_Allowed() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal64(100L, scale: 2); + Assert.DoesNotThrow(() => col.AppendDecimal64(2000L, scale: 3)); + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + } + + [Test] + public void AppendDecimal64_Limbs_LossyRescale_Throws() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal64(100L, scale: 2); + var ex = Assert.Throws(() => col.AppendDecimal64(201L, scale: 3)); + Assert.That(ex!.Message, Does.Contain("precision loss")); + } + + [Test] + public void AppendDecimal256_Limbs_ScaleAboveMax_Throws() + { + var col = new QwpColumn("p", 0); + Assert.Throws(() => col.AppendDecimal256(0, 0, 0, 0, scale: 77)); + } + + [Test] + public void AppendDecimal64_LimbAndDecimalForms_ShareScaleLock() + { + var col = new QwpColumn("p", 0); + col.AppendDecimal64(100L, scale: 2); + col.AppendDecimal64(0.55m); + Assert.That(col.DecimalScale, Is.EqualTo((byte)2)); + Assert.That(col.NonNullCount, Is.EqualTo(2)); + var span = col.FixedData!.AsSpan(0, 16); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(0, 8)), Is.EqualTo(100L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(8, 8)), Is.EqualTo(55L)); + } + + /// Reads a 16-byte little-endian two's-complement integer. + private static BigInteger ReadInt128(ReadOnlySpan bytes) + { + return new BigInteger(bytes, isUnsigned: false, isBigEndian: false); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpColumnTests.cs b/src/net-questdb-client-tests/Qwp/QwpColumnTests.cs new file mode 100644 index 00000000..2c32a68a --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpColumnTests.cs @@ -0,0 +1,394 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpColumnTests +{ + [Test] + public void NewColumn_NoAppends_HasNoTypeAndNoBitmap() + { + var col = new QwpColumn("c", initialNullRows: 0); + Assert.That(col.Name, Is.EqualTo("c")); + Assert.That(col.IsTyped, Is.False); + Assert.That(col.RowCount, Is.Zero); + Assert.That(col.NullCount, Is.Zero); + Assert.That(col.NonNullCount, Is.Zero); + Assert.That(col.NullBitmap, Is.Null); + } + + [Test] + public void AppendLong_TypeLockedAfterFirstCall() + { + var col = new QwpColumn("c", 0); + col.AppendLong(42); + + Assert.That(col.IsTyped); + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Long)); + Assert.That(col.RowCount, Is.EqualTo(1)); + Assert.That(col.NonNullCount, Is.EqualTo(1)); + Assert.That(col.NullCount, Is.Zero); + Assert.That(col.FixedData!.AsSpan(0, 8).ToArray(), + Is.EqualTo(new byte[] { 42, 0, 0, 0, 0, 0, 0, 0 })); + } + + [Test] + public void AppendDouble_AfterAppendLong_Throws() + { + var col = new QwpColumn("c", 0); + col.AppendLong(1); + Assert.Throws(() => col.AppendDouble(1.0)); + } + + [Test] + public void AppendNull_AllocatesBitmapAndAdvancesRowCount() + { + var col = new QwpColumn("c", 0); + col.AppendLong(1); + col.AppendNull(); + col.AppendLong(2); + + Assert.That(col.RowCount, Is.EqualTo(3)); + Assert.That(col.NullCount, Is.EqualTo(1)); + Assert.That(col.NonNullCount, Is.EqualTo(2)); + Assert.That(col.NullBitmap, Is.Not.Null); + // Row 0 = non-null, row 1 = null, row 2 = non-null. Expected bitmap byte 0 = 0b00000010 = 2. + Assert.That(col.NullBitmap![0], Is.EqualTo(0x02)); + // FixedData has only the two non-null values densely packed. + Assert.That(col.FixedLen, Is.EqualTo(16)); + } + + [Test] + public void NullBitmap_LazyAllocatedAtRow50_BackfillsAllPriorNonNullsAsZero() + { + var col = new QwpColumn("c", 0); + for (var i = 0; i < 50; i++) + { + col.AppendLong(i); + } + + Assert.That(col.NullBitmap, Is.Null, "no nulls yet → bitmap stays unallocated"); + + col.AppendNull(); + + Assert.That(col.NullBitmap, Is.Not.Null); + Assert.That(col.RowCount, Is.EqualTo(51)); + Assert.That(col.NullCount, Is.EqualTo(1)); + + for (var byteIdx = 0; byteIdx < 6; byteIdx++) + { + Assert.That(col.NullBitmap![byteIdx], Is.EqualTo(0), + $"byte {byteIdx}: rows 0..{byteIdx * 8 + 7} were all non-null"); + } + + const int nullBit = 50 % 8; + Assert.That(col.NullBitmap![6], Is.EqualTo((byte)(1 << nullBit)), + "only bit 50%8 in byte 6 is set; bits 0..1 (rows 48..49) and 3..7 stay zero"); + } + + [Test] + public void Constructor_BackfillsLeadingNulls() + { + var col = new QwpColumn("c", initialNullRows: 4); + col.AppendLong(99); + + Assert.That(col.RowCount, Is.EqualTo(5)); + Assert.That(col.NullCount, Is.EqualTo(4)); + Assert.That(col.NonNullCount, Is.EqualTo(1)); + Assert.That(col.NullBitmap, Is.Not.Null); + // Rows 0..3 null → bits 0..3 set → byte 0 = 0b00001111 = 0x0F. + Assert.That(col.NullBitmap![0], Is.EqualTo(0x0F)); + } + + [Test] + public void AppendBool_BitPacksLittleEndianWithinByte() + { + var col = new QwpColumn("c", 0); + // True, False, True, True, False, False, False, True + // → bits[0..7] = 1,0,1,1,0,0,0,1 → byte 0 = 0b10001101 = 0x8D + col.AppendBool(true); + col.AppendBool(false); + col.AppendBool(true); + col.AppendBool(true); + col.AppendBool(false); + col.AppendBool(false); + col.AppendBool(false); + col.AppendBool(true); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Boolean)); + Assert.That(col.NonNullCount, Is.EqualTo(8)); + Assert.That(col.BoolData![0], Is.EqualTo(0x8D)); + } + + [Test] + public void AppendBool_SavepointRestore_ClearsStaleBitsInActiveByte() + { + var col = new QwpColumn("c", 0); + col.AppendBool(true); + col.AppendBool(true); + + var sp = col.Snapshot(); + col.AppendBool(true); + col.Restore(sp); + col.AppendBool(false); + + Assert.That(col.NonNullCount, Is.EqualTo(3)); + Assert.That(col.BoolData![0], Is.EqualTo(0x03)); + } + + [Test] + public void AppendVarchar_OffsetsAndDataMatchSpecExample2() + { + // Spec §16 example 2: 4 rows, row 1 null, values "foo", "bar", "baz". + var col = new QwpColumn("s", 0); + col.AppendVarchar("foo"); + col.AppendNull(); + col.AppendVarchar("bar"); + col.AppendVarchar("baz"); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Varchar)); + Assert.That(col.RowCount, Is.EqualTo(4)); + Assert.That(col.NullCount, Is.EqualTo(1)); + Assert.That(col.NonNullCount, Is.EqualTo(3)); + + Assert.That(col.NullBitmap![0], Is.EqualTo(0x02), "row 1 bit set"); + + // Offsets should be [0, 3, 6, 9] (start of foo, end of foo / start of bar, ...). + Assert.That(col.StrOffsets![0], Is.EqualTo(0u)); + Assert.That(col.StrOffsets[1], Is.EqualTo(3u)); + Assert.That(col.StrOffsets[2], Is.EqualTo(6u)); + Assert.That(col.StrOffsets[3], Is.EqualTo(9u)); + + Assert.That(col.StrLen, Is.EqualTo(9)); + Assert.That(col.StrData!.AsSpan(0, 9).ToArray(), + Is.EqualTo(new byte[] { 0x66, 0x6F, 0x6F, 0x62, 0x61, 0x72, 0x62, 0x61, 0x7A })); + } + + [Test] + public void AppendSymbol_StoresIdsInOrder() + { + var col = new QwpColumn("ticker", 0); + col.AppendSymbol(0); + col.AppendNull(); + col.AppendSymbol(7); + col.AppendSymbol(7); + + Assert.That(col.TypeCode, Is.EqualTo(QwpTypeCode.Symbol)); + Assert.That(col.SymbolIds![0], Is.EqualTo(0)); + Assert.That(col.SymbolIds[1], Is.EqualTo(7)); + Assert.That(col.SymbolIds[2], Is.EqualTo(7)); + } + + [Test] + public void AppendUuid_LowHalfFirstThenHighHalf_BothLittleEndian() + { + // Pick a UUID where every byte differs so we can spot-check positions. + // Guid layout: 12345678-9abc-def0-fedc-ba9876543210 + var guid = Guid.Parse("12345678-9abc-def0-fedc-ba9876543210"); + var col = new QwpColumn("u", 0); + col.AppendUuid(guid); + + // RFC 4122 representation of the UUID (big-endian): + // 12 34 56 78 9a bc de f0 fe dc ba 98 76 54 32 10 + // High 64 = 0x12345678_9abcdef0 + // Low 64 = 0xfedcba98_76543210 + // QWP wire = LE(low 64) followed by LE(high 64): + // bytes 0..7 = LE(0xfedcba98_76543210) = 10 32 54 76 98 ba dc fe + // bytes 8..15 = LE(0x12345678_9abcdef0) = f0 de bc 9a 78 56 34 12 + var expected = new byte[] + { + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, + 0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12, + }; + Assert.That(col.FixedData!.AsSpan(0, 16).ToArray(), Is.EqualTo(expected)); + } + + [Test] + public void AppendChar_WritesTwoBytesLittleEndian() + { + var col = new QwpColumn("c", 0); + col.AppendChar('A'); // U+0041 + col.AppendChar('中'); // CJK ideograph + + Assert.That(col.FixedData!.AsSpan(0, 4).ToArray(), + Is.EqualTo(new byte[] { 0x41, 0x00, 0x2d, 0x4e })); + } + + [Test] + public void BufferedBytes_FixedWidthColumn_CountsStoredBytes() + { + var col = new QwpColumn("c", 0); + Assert.That(col.BufferedBytes, Is.Zero); + + col.AppendLong(1); + col.AppendLong(2); + col.AppendNull(); + + // Two LONG values densely packed = 16 bytes; the null adds no fixed-width data. + Assert.That(col.BufferedBytes, Is.EqualTo(16)); + } + + [Test] + public void BufferedBytes_VarcharColumn_CountsDataAndOffsetSlots() + { + var col = new QwpColumn("s", 0); + col.AppendVarchar("foo"); + col.AppendVarchar("bar"); + + // StrLen = 6 bytes of UTF-8 data + (NonNullCount + 1) = 3 uint offset slots. + Assert.That(col.BufferedBytes, Is.EqualTo(6 + 3 * sizeof(uint))); + } + + [Test] + public void BufferedBytes_SymbolColumn_CountsIdWidths() + { + var col = new QwpColumn("ticker", 0); + col.AppendSymbol(0); + col.AppendSymbol(7); + col.AppendSymbol(3); + + // 3 non-null ids, each accounted at sizeof(int). + Assert.That(col.BufferedBytes, Is.EqualTo(3 * sizeof(int))); + } + + [Test] + public void BufferedBytes_BoolColumn_CountsBitPackedBytes() + { + var col = new QwpColumn("flag", 0); + for (var i = 0; i < 9; i++) + { + col.AppendBool(i % 2 == 0); + } + + // 9 bit-packed booleans = ceil(9 / 8) = 2 bytes. + Assert.That(col.BufferedBytes, Is.EqualTo(2)); + } + + [Test] + public void AppendLong_LargeRunGrowsBuffer() + { + var col = new QwpColumn("c", 0); + for (var i = 0; i < 100; i++) + { + col.AppendLong(i); + } + + Assert.That(col.RowCount, Is.EqualTo(100)); + Assert.That(col.NonNullCount, Is.EqualTo(100)); + Assert.That(col.NullBitmap, Is.Null, "no nulls means no bitmap allocated"); + + // Spot-check value at index 50. + var span = col.FixedData!.AsSpan(50 * 8, 8); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(span), Is.EqualTo(50)); + } + + // Regression: Clear() zeroes the lengths but keeps the backing arrays non-null; + // a subsequent TrimToCurrent() then Array.Resizes them to length 0 (still non-null). + // The next append hits GrowTo(0, required), which doubled 0*2==0 forever — a hard + // CPU hang on the producer thread. Each grow path is exercised separately because + // they resize different backing arrays. The triggering append runs on a background + // thread guarded by a timeout so the buggy path fails fast instead of hanging the + // whole suite. + [Test] + public void Append_AfterClearThenTrim_DoesNotHang_Fixed() + { + var col = new QwpColumn("c", 0); + col.AppendLong(42); + col.Clear(); + col.TrimToCurrent(); + + AssertCompletesQuickly(() => col.AppendLong(7)); + Assert.That(col.RowCount, Is.EqualTo(1)); + } + + [Test] + public void Append_AfterClearThenTrim_DoesNotHang_Varchar() + { + var col = new QwpColumn("c", 0); + col.AppendVarchar("hello".AsSpan()); + col.Clear(); + col.TrimToCurrent(); + + AssertCompletesQuickly(() => col.AppendVarchar("world".AsSpan())); + Assert.That(col.RowCount, Is.EqualTo(1)); + } + + [Test] + public void Append_AfterClearThenTrim_DoesNotHang_Symbol() + { + var col = new QwpColumn("c", 0); + col.AppendSymbol(3); + col.Clear(); + col.TrimToCurrent(); + + AssertCompletesQuickly(() => col.AppendSymbol(5)); + Assert.That(col.RowCount, Is.EqualTo(1)); + } + + [Test] + public void Append_AfterClearThenTrim_DoesNotHang_Bool() + { + var col = new QwpColumn("c", 0); + col.AppendBool(true); + col.Clear(); + col.TrimToCurrent(); + + AssertCompletesQuickly(() => col.AppendBool(false)); + Assert.That(col.RowCount, Is.EqualTo(1)); + } + + private static void AssertCompletesQuickly(System.Action action) + { + System.Exception? captured = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (System.Exception ex) + { + captured = ex; + } + }) + { + IsBackground = true, // a hang must not keep a live foreground thread spinning after the run. + }; + + thread.Start(); + var completed = thread.Join(System.TimeSpan.FromSeconds(5)); + + Assert.That(completed, Is.True, "append did not complete — infinite loop in GrowTo(0, required)"); + if (captured is not null) + { + throw captured; + } + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpConnectionEventDispatcherTests.cs b/src/net-questdb-client-tests/Qwp/QwpConnectionEventDispatcherTests.cs new file mode 100644 index 00000000..8d5d1b69 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpConnectionEventDispatcherTests.cs @@ -0,0 +1,110 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp; +using QuestDB.Senders; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpConnectionEventDispatcherTests +{ + private sealed class CountingListener : ISenderConnectionListener + { + private long _count; + public long Count => Interlocked.Read(ref _count); + public void OnEvent(SenderConnectionEvent evt) => Interlocked.Increment(ref _count); + } + + private static SenderConnectionEvent MakeEvent() => new( + SenderConnectionEventKind.Connected, "localhost", 9000, + SenderConnectionEvent.NoCounter, SenderConnectionEvent.NoCounter, + null, DateTimeOffset.UtcNow); + + [Test] + public void NeverStarted_DisposesCleanly() + { + var dispatcher = new QwpConnectionEventDispatcher(new CountingListener(), capacity: 8); + Assert.DoesNotThrow(() => dispatcher.Dispose()); + } + + [Test] + public void Offer_RacingFirstOfferAgainstDispose_NoUnobservedTaskAndCleanDisposal() + { + // The first Offer lazily starts the dispatch loop; racing it against Dispose used to let + // Dispose skip the join and dispose the shutdown CTS out from under a starting loop. + for (var i = 0; i < 200; i++) + { + var listener = new CountingListener(); + var dispatcher = new QwpConnectionEventDispatcher(listener, capacity: 8); + var start = new ManualResetEventSlim(); + + var offerThread = new Thread(() => + { + start.Wait(); + dispatcher.Offer(MakeEvent()); + }); + var disposeThread = new Thread(() => + { + start.Wait(); + dispatcher.Dispose(); + }); + + offerThread.Start(); + disposeThread.Start(); + start.Set(); + + Assert.That(offerThread.Join(TimeSpan.FromSeconds(5)), Is.True, "Offer thread wedged"); + Assert.That(disposeThread.Join(TimeSpan.FromSeconds(5)), Is.True, "Dispose thread wedged"); + + // After Dispose returns, no dispatch-loop task may still be running unobserved: if the + // loop started it delivered the queued event; if it never started, nothing was queued + // for delivery. Either way the count is stable once both threads have joined. + var settled = listener.Count; + Thread.Sleep(20); + Assert.That(listener.Count, Is.EqualTo(settled), + "dispatch loop still running after Dispose returned"); + Assert.That(settled, Is.LessThanOrEqualTo(1)); + } + } + + [Test] + public void DeliversQueuedEvents_WhenNotRacingDispose() + { + var listener = new CountingListener(); + using (var dispatcher = new QwpConnectionEventDispatcher(listener, capacity: 8)) + { + for (var i = 0; i < 4; i++) dispatcher.Offer(MakeEvent()); + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (listener.Count < 4 && DateTime.UtcNow < deadline) + { + Thread.Sleep(5); + } + } + + Assert.That(listener.Count, Is.EqualTo(4)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpEncoderTests.cs b/src/net-questdb-client-tests/Qwp/QwpEncoderTests.cs new file mode 100644 index 00000000..d48aa88d --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpEncoderTests.cs @@ -0,0 +1,776 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Numerics; +using System.Text; +using NUnit.Framework; +using QuestDB.Qwp; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpEncoderTests +{ + [Test] + public void Encode_SingleTable_NoSymbols_ProducesByteExactFrame() + { + var t = new QwpTableBuffer("sensors"); + t.AppendLong("id", 1); + t.AppendDouble("value", 1.3); + t.At(10_000_000_000L); + + t.AppendLong("id", 2); + t.AppendDouble("value", 2.2); + t.At(400_000L); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + + var expected = ConcatBytes( + // header: magic "QWP1", version 1, flags 0x0C (DELTA_SYMBOL_DICT | GORILLA), + // tableCount 1, payloadLen 0x4D = 77. + new byte[] { 0x51, 0x57, 0x50, 0x31, 0x01, 0x0C, 0x01, 0x00, 0x4D, 0x00, 0x00, 0x00 }, + new byte[] { 0x00, 0x00 }, + new byte[] { 0x07 }, + Encoding.UTF8.GetBytes("sensors"), + new byte[] { 0x02, 0x03 }, + new byte[] { 0x02 }, Encoding.UTF8.GetBytes("id"), new byte[] { 0x05 }, + new byte[] { 0x05 }, Encoding.UTF8.GetBytes("value"), new byte[] { 0x07 }, + new byte[] { 0x00, 0x0A }, + new byte[] { 0x00 }, + LittleEndianInt64(1L), + LittleEndianInt64(2L), + new byte[] { 0x00 }, + LittleEndianDouble(1.3), + LittleEndianDouble(2.2), + new byte[] { 0x00 }, // designated-TS null flag + new byte[] { 0x00 }, // Gorilla encoding flag: 0x00 uncompressed (< 3 values) + LittleEndianInt64(10_000_000_000L), + LittleEndianInt64(400_000L)); + + Assert.That(bytes, Is.EqualTo(expected), + $"\nactual: {Hex(bytes)}\nexpected: {Hex(expected)}"); + } + + [Test] + public void Encode_SecondCallSameSchema_StillEmitsInlineSchema() + { + // Schemas always travel inline now; encoding the same buffer twice produces frames whose + // table-block schema sections are byte-identical (modulo row data). + var symbols = new QwpSymbolDictionary(); + + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + + var first = QwpEncoder.Encode(new[] { t }, symbols); + + t.Clear(); + t.AppendLong("x", 2); + t.At(1); + + var second = QwpEncoder.Encode(new[] { t }, symbols); + + // Both frames must start with the same prelude+table-name+row_count+col_count+column-defs + // prefix — i.e. the schema is re-emitted inline on every call. + // header (12) + delta dict (2) + table name varint (1) + "t" (1) + // + row count (1) + col count (1) + col name varint (1) + "x" (1) + type code (1) = 21 + const int schemaPrefixEnd = 21; + Assert.That(first.AsSpan(14, schemaPrefixEnd - 14).ToArray(), + Is.EqualTo(second.AsSpan(14, schemaPrefixEnd - 14).ToArray()), + "table-block schema section must be byte-identical when re-encoded"); + } + + [Test] + public void Encode_NewColumnMidStream_ReEmitsFullSchema() + { + // A new column changes the inline schema; the next frame's column-defs section grows + // accordingly. (There is no schema-id machinery to invalidate.) + var symbols = new QwpSymbolDictionary(); + + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + var first = QwpEncoder.Encode(new[] { t }, symbols); + + t.Clear(); + t.AppendLong("x", 2); + t.AppendDouble("y", 3.14); + t.At(1); + + var second = QwpEncoder.Encode(new[] { t }, symbols); + + // Second frame must be longer (extra column def + extra column-data section). + Assert.That(second.Length, Is.GreaterThan(first.Length), + "adding a column grows the next frame's inline schema and data sections"); + } + + [Test] + public void Encode_WithSymbols_EmitsDeltaDictAndVarintIds() + { + var symbols = new QwpSymbolDictionary(); + + var idUs = symbols.Add("us"); + var idEu = symbols.Add("eu"); + + var t = new QwpTableBuffer("trades"); + t.AppendSymbol("region", idUs); + t.At(0); + t.AppendSymbol("region", idEu); + t.At(1); + t.AppendSymbol("region", idUs); + t.At(2); + + var bytes = QwpEncoder.Encode(new[] { t }, symbols); + + // Header at bytes 0..11: + Assert.That(bytes[QwpConstants.OffsetVersion], Is.EqualTo(QwpConstants.SupportedVersion)); + Assert.That(bytes[QwpConstants.OffsetFlags], + Is.EqualTo((byte)(QwpConstants.FlagDeltaSymbolDict | QwpConstants.FlagGorilla))); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(QwpConstants.OffsetTableCount, 2)), Is.EqualTo(1)); + + // Delta dict prelude immediately after the 12-byte header: + // delta_start = 0 (varint 0x00) + // delta_count = 2 (varint 0x02) + // "us": len 2, bytes 'u' 's' + // "eu": len 2, bytes 'e' 'u' + Assert.That(bytes[12], Is.EqualTo(0x00), "delta_start = 0"); + Assert.That(bytes[13], Is.EqualTo(0x02), "delta_count = 2"); + Assert.That(bytes[14], Is.EqualTo(0x02), "len('us') = 2"); + Assert.That(bytes.AsSpan(15, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("us"))); + Assert.That(bytes[17], Is.EqualTo(0x02), "len('eu') = 2"); + Assert.That(bytes.AsSpan(18, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("eu"))); + } + + [Test] + public void Encode_WithCommittedSymbols_EmitsOnlyDelta() + { + var symbols = new QwpSymbolDictionary(); + + // First batch: add "us", emit, commit. + symbols.Add("us"); + var t = new QwpTableBuffer("trades"); + t.AppendSymbol("region", 0); + t.At(0); + QwpEncoder.Encode(new[] { t }, symbols); + symbols.Commit(); // server ACKed. + + // Second batch: add "eu" (delta is just "eu"). + symbols.Add("eu"); + t.Clear(); + t.AppendSymbol("region", 1); + t.At(1); + + var bytes = QwpEncoder.Encode(new[] { t }, symbols); + + // Expect delta_start = 1, delta_count = 1, single entry "eu". + Assert.That(bytes[12], Is.EqualTo(0x01), "delta_start = 1 (committed_count)"); + Assert.That(bytes[13], Is.EqualTo(0x01), "delta_count = 1"); + Assert.That(bytes[14], Is.EqualTo(0x02), "len('eu') = 2"); + Assert.That(bytes.AsSpan(15, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("eu"))); + } + + [Test] + public void Encode_NullableVarcharColumn_OffsetsAndBitmapMatchSpecExample2() + { + // Spec §16 example 2: nullable string column, 4 rows, row 1 null. + var t = new QwpTableBuffer("notes"); + t.AppendVarchar("note", "foo"); + t.At(0); + t.At(1); // row 1: column "note" not touched → null padded. + t.AppendVarchar("note", "bar"); + t.At(2); + t.AppendVarchar("note", "baz"); + t.At(3); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + + // Find the column-data section. Layout: + // header (12) + delta dict (2) + table header (1+5+1+1=8) + // + col defs: "note" varchar (1+4+1=6) + "" timestamp (1+1=2) + // = 12 + 2 + 8 + 6 + 2 = 30 → first col data at offset 30. + var pos = 30; + + // Null flag: 0x01 (bitmap follows) + Assert.That(bytes[pos++], Is.EqualTo(0x01)); + + // Bitmap: ceil(4 / 8) = 1 byte, with bit 1 set for the null row → 0b00000010 = 0x02. + Assert.That(bytes[pos++], Is.EqualTo(0x02)); + + // Offset array: 4 uint32 LE values = [0, 3, 6, 9] + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos, 4)), Is.EqualTo(0u)); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos + 4, 4)), Is.EqualTo(3u)); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos + 8, 4)), Is.EqualTo(6u)); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos + 12, 4)), Is.EqualTo(9u)); + pos += 16; + + // String data: "foobarbaz" UTF-8 (9 bytes) + Assert.That(bytes.AsSpan(pos, 9).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("foobarbaz"))); + } + + [Test] + public void Encode_MultipleTables_EmitsOneFrameWithBothBlocks() + { + var symbols = new QwpSymbolDictionary(); + + var t1 = new QwpTableBuffer("a"); + t1.AppendLong("v", 1); + t1.At(0); + + var t2 = new QwpTableBuffer("b"); + t2.AppendDouble("v", 2.0); + t2.At(0); + + var bytes = QwpEncoder.Encode(new[] { t1, t2 }, symbols); + + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(QwpConstants.OffsetTableCount, 2)), + Is.EqualTo(2), "two tables in one frame"); + } + + [Test] + public void Encode_EmptyTablesList_ProducesValidEmptyFrame() + { + var bytes = QwpEncoder.Encode(Array.Empty(), new QwpSymbolDictionary()); + + Assert.That(bytes.Length, Is.EqualTo(QwpConstants.HeaderSize + 2), + "header + empty delta dict (00 00)"); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(QwpConstants.OffsetTableCount, 2)), Is.Zero); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(QwpConstants.OffsetPayloadLength, 4)), Is.EqualTo(2u)); + } + + [Test] + public void Encode_DeferCommit_SetsFlagBit() + { + var t = new QwpTableBuffer("trades"); + t.AppendLong("v", 1L); + t.At(0); + + var deferred = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary(), deferCommit: true); + Assert.That(deferred[QwpConstants.OffsetFlags], + Is.EqualTo((byte)(QwpConstants.FlagDeltaSymbolDict | QwpConstants.FlagGorilla | QwpConstants.FlagDeferCommit))); + + var committing = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + Assert.That(committing[QwpConstants.OffsetFlags] & QwpConstants.FlagDeferCommit, Is.Zero, + "default encode must not set the defer-commit flag"); + } + + [Test] + public void Encode_MagicBytesAreCorrect() + { + var bytes = QwpEncoder.Encode(Array.Empty(), new QwpSymbolDictionary()); + Assert.That(bytes[0], Is.EqualTo((byte)'Q')); + Assert.That(bytes[1], Is.EqualTo((byte)'W')); + Assert.That(bytes[2], Is.EqualTo((byte)'P')); + Assert.That(bytes[3], Is.EqualTo((byte)'1')); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(0, 4)), Is.EqualTo(QwpConstants.Magic)); + } + + [Test] + public void Encode_Decimal128Column_WritesScalePrefixAndUnscaledBytes() + { + var t = new QwpTableBuffer("t"); + t.AppendDecimal128("price", 12.34m); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + + // Column-data offset: header (12) + delta (2) + table header "t" 1+1+1+1 = 4 + // + schema 2 + col defs: "price" 1+5+1=7 + "" timestamp 1+1=2 = 29. + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 7); + + // Null flag = 0 (no nulls). + Assert.That(bytes[pos++], Is.EqualTo(0x00)); + + // Scale prefix = 2. + Assert.That(bytes[pos++], Is.EqualTo((byte)2)); + + // 16 bytes LE two's complement of 1234. + var unscaled = bytes.AsSpan(pos, 16); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(0, 8)), Is.EqualTo(1234L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(8, 8)), Is.EqualTo(0L)); + } + + [Test] + public void Encode_CharColumn_WritesTwoBytesLittleEndian() + { + var t = new QwpTableBuffer("t"); + t.AppendChar("c", 'A'); + t.At(0); + t.AppendChar("c", '中'); + t.At(1); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(pos, 2)), Is.EqualTo((ushort)'A')); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(pos + 2, 2)), Is.EqualTo((ushort)'中')); + } + + [Test] + public void Encode_Decimal64Column_WritesScalePrefixAndEightBytesPerValue() + { + var t = new QwpTableBuffer("t"); + t.AppendDecimal64("p", 12.34m); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos++], Is.EqualTo((byte)2), "scale = 2"); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(pos, 8)), Is.EqualTo(1234L)); + } + + [Test] + public void Encode_Decimal256Column_WritesScalePrefixAnd32BytesPerValue() + { + var t = new QwpTableBuffer("t"); + t.AppendDecimal256("p", -1m); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos++], Is.EqualTo((byte)0), "scale = 0"); + for (var i = 0; i < 32; i++) + { + Assert.That(bytes[pos + i], Is.EqualTo((byte)0xFF), $"-1 LE two's-complement byte {i}"); + } + } + + [Test] + public void Encode_Decimal64Column_LimbForm_WritesScaleAndUnscaledLimb() + { + var t = new QwpTableBuffer("t"); + t.AppendDecimal64("p", 1234567890123L, scale: 4); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos++], Is.EqualTo((byte)4), "scale = 4"); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(pos, 8)), + Is.EqualTo(1234567890123L)); + } + + [Test] + public void Encode_Decimal128Column_LimbForm_WritesLoThenHiLittleEndian() + { + var t = new QwpTableBuffer("t"); + t.AppendDecimal128("p", lo: 0x0102030405060708L, hi: 0x1112131415161718L, scale: 6); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos++], Is.EqualTo((byte)6), "scale = 6"); + var span = bytes.AsSpan(pos, 16); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(0, 8)), Is.EqualTo(0x0102030405060708L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(8, 8)), Is.EqualTo(0x1112131415161718L)); + } + + [Test] + public void Encode_Decimal256Column_LimbForm_WritesFourLimbsLsbFirst() + { + var t = new QwpTableBuffer("t"); + t.AppendDecimal256("p", + l0: 0x0102030405060708L, + l1: 0x1112131415161718L, + l2: 0x2122232425262728L, + l3: 0x3132333435363738L, + scale: 12); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos++], Is.EqualTo((byte)12), "scale = 12"); + var span = bytes.AsSpan(pos, 32); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(0, 8)), Is.EqualTo(0x0102030405060708L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(8, 8)), Is.EqualTo(0x1112131415161718L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(16, 8)), Is.EqualTo(0x2122232425262728L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(span.Slice(24, 8)), Is.EqualTo(0x3132333435363738L)); + } + + [Test] + public void Encode_BinaryColumn_WritesOffsetsThenBytes() + { + var t = new QwpTableBuffer("t"); + t.AppendBinary("blob", new byte[] { 0x10, 0x20 }); + t.At(0); + t.AppendBinary("blob", new byte[] { 0x30 }); + t.At(1); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 4 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + // (n+1)=3 offsets × u32 LE: 0, 2, 3 + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos, 4)), Is.EqualTo(0u)); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos + 4, 4)), Is.EqualTo(2u)); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos + 8, 4)), Is.EqualTo(3u)); + // Then the 3 bytes of payload. + Assert.That(bytes[pos + 12], Is.EqualTo(0x10)); + Assert.That(bytes[pos + 13], Is.EqualTo(0x20)); + Assert.That(bytes[pos + 14], Is.EqualTo(0x30)); + } + + [Test] + public void Encode_IPv4Column_WritesFourBytesLittleEndian() + { + var t = new QwpTableBuffer("t"); + t.AppendIPv4("ip", 0x04030201u); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 2 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos, 4)), Is.EqualTo(0x04030201u)); + } + + [Test] + public void Encode_Long256Column_Writes32BytesPerValue() + { + var t = new QwpTableBuffer("t"); + t.AppendLong256("hash", new BigInteger(0xCAFEBABEUL)); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 4 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(pos, 4)), Is.EqualTo(0xCAFEBABEu)); + for (var i = 4; i < 32; i++) + { + Assert.That(bytes[pos + i], Is.Zero, $"high byte {i}"); + } + } + + [Test] + public void Encode_GeohashColumn_WritesPrecisionVarintAndPackedValues() + { + var t = new QwpTableBuffer("t"); + t.AppendGeohash("loc", 0xABCDEF, 24); + t.At(0); + t.AppendGeohash("loc", 0x123456, 24); + t.At(1); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 3 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos++], Is.EqualTo((byte)24), "precision varint = 24"); + + // 2 values × 3 bytes each, LE packed. + Assert.That(bytes[pos], Is.EqualTo(0xEF)); + Assert.That(bytes[pos + 1], Is.EqualTo(0xCD)); + Assert.That(bytes[pos + 2], Is.EqualTo(0xAB)); + Assert.That(bytes[pos + 3], Is.EqualTo(0x56)); + Assert.That(bytes[pos + 4], Is.EqualTo(0x34)); + Assert.That(bytes[pos + 5], Is.EqualTo(0x12)); + } + + [Test] + public void Encode_DoubleArrayColumn_WritesPerRowHeaderAndValues() + { + var t = new QwpTableBuffer("t"); + t.AppendDoubleArray("vec", new double[] { 1.0, 2.0 }, new[] { 2 }); + t.At(0); + t.AppendDoubleArray("vec", new double[] { 9.0 }, new[] { 1 }); + t.At(1); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 3 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + + // Row 0: n_dims=1, dim_lens=[2], values=[1.0, 2.0]. + Assert.That(bytes[pos], Is.EqualTo((byte)1)); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(pos + 1, 4)), Is.EqualTo(2)); + Assert.That(BinaryPrimitives.ReadDoubleLittleEndian(bytes.AsSpan(pos + 5, 8)), Is.EqualTo(1.0)); + Assert.That(BinaryPrimitives.ReadDoubleLittleEndian(bytes.AsSpan(pos + 13, 8)), Is.EqualTo(2.0)); + pos += 1 + 4 + 16; + + // Row 1: n_dims=1, dim_lens=[1], values=[9.0]. + Assert.That(bytes[pos], Is.EqualTo((byte)1)); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(pos + 1, 4)), Is.EqualTo(1)); + Assert.That(BinaryPrimitives.ReadDoubleLittleEndian(bytes.AsSpan(pos + 5, 8)), Is.EqualTo(9.0)); + } + + [Test] + public void Encode_SetsGorillaHeaderFlagAndPrependsEncodingByte() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("v", 1L); + t.At(1_000_000L); + t.AppendLong("v", 2L); + t.At(1_000_001L); + t.AppendLong("v", 3L); + t.At(1_000_002L); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + + var expectedFlags = (byte)(QwpConstants.FlagDeltaSymbolDict | QwpConstants.FlagGorilla); + Assert.That(bytes[QwpConstants.OffsetFlags], Is.EqualTo(expectedFlags)); + + // header(12) + delta(2) + tableNameLen(1) + "t"(1) + rowCount(1) + colCount(1) + // + col defs: "v" LONG (1+1+1=3) + "" TIMESTAMP (1+1=2) = 23. + var pos = 23; + Assert.That(bytes[pos++], Is.EqualTo(0x00), "long col null flag"); + // 3 longs = 24 bytes + pos += 24; + + // Designated TS column: the encoding-flag byte is always present. + Assert.That(bytes[pos++], Is.EqualTo(0x00), "ts col null flag"); + var encodingFlag = bytes[pos]; + Assert.That(encodingFlag is QwpGorilla.EncodingUncompressed or QwpGorilla.EncodingGorilla, + "encoding flag must be 0x00 or 0x01"); + } + + [Test] + public void Encode_LongArrayColumn_WritesInt64Values() + { + var t = new QwpTableBuffer("t"); + t.AppendLongArray("v", new long[] { -7, 7 }, new[] { 2 }); + t.At(0); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSymbolDictionary()); + var pos = FindFirstColumnDataOffset(bytes, tableNameLen: 1, userColCount: 1, userColDefSize: 1 + 1 + 1); + + Assert.That(bytes[pos++], Is.EqualTo(0x00), "null flag"); + Assert.That(bytes[pos], Is.EqualTo((byte)1)); // n_dims + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(pos + 1, 4)), Is.EqualTo(2)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(pos + 5, 8)), Is.EqualTo(-7L)); + Assert.That(BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(pos + 13, 8)), Is.EqualTo(7L)); + } + + [Test] + public void Encode_SelfSufficient_AlwaysEmitsFullSchema() + { + var symbols = new QwpSymbolDictionary(); + + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + + var first = QwpEncoder.Encode(new[] { t }, symbols, selfSufficient: true); + + t.Clear(); + t.AppendLong("x", 2); + t.At(1); + + var second = QwpEncoder.Encode(new[] { t }, symbols, selfSufficient: true); + + // Both frames carry the same inline schema; receiver needs no prior connection state. + // Schema spans header (12) + dict (2) + name varint (1) + "t" (1) + row count (1) + // + col count (1) + col name varint (1) + "x" (1) + type code (1) = 21. + const int schemaPrefixEnd = 21; + Assert.That(first.AsSpan(14, schemaPrefixEnd - 14).ToArray(), + Is.EqualTo(second.AsSpan(14, schemaPrefixEnd - 14).ToArray()), + "SF frames must re-emit identical inline schemas every time"); + } + + [Test] + public void Encode_SelfSufficient_EmitsFullSymbolDictAfterCommit() + { + var symbols = new QwpSymbolDictionary(); + + symbols.Add("us"); + symbols.Add("eu"); + // Pretend the server already acked these in a prior connection. + symbols.Commit(); + + var t = new QwpTableBuffer("trades"); + t.AppendSymbol("region", 0); + t.At(0); + t.AppendSymbol("region", 1); + t.At(1); + + var bytes = QwpEncoder.Encode(new[] { t }, symbols, selfSufficient: true); + + // SF replay: every frame restates the dictionary from id 0. + Assert.That(bytes[12], Is.EqualTo(0x00), "delta_start = 0 in SF mode (committed watermark ignored)"); + Assert.That(bytes[13], Is.EqualTo(0x02), "delta_count = full Count"); + Assert.That(bytes[14], Is.EqualTo(0x02), "len('us')"); + Assert.That(bytes.AsSpan(15, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("us"))); + Assert.That(bytes[17], Is.EqualTo(0x02), "len('eu')"); + Assert.That(bytes.AsSpan(18, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("eu"))); + } + + [Test] + public void EncodeInto_SelfSufficient_SymbolDeltaCount_EmitsOnlyDictPrefix() + { + var symbols = new QwpSymbolDictionary(); + symbols.Add("us"); + symbols.Add("eu"); + symbols.Add("ap"); + + var t = new QwpTableBuffer("trades"); + t.AppendSymbol("region", 0); + t.At(0); + t.AppendSymbol("region", 1); + t.At(1); + + // Self-sufficient with an explicit prefix length of 2: only ids [0, 2) are restated. + var builder = new QwpEncoder.FrameBuilder(4096); + var len = QwpEncoder.EncodeInto(builder, new[] { t }, symbols, + selfSufficient: true, symbolDeltaCount: 2); + var bytes = builder.AsSpan(0, len).ToArray(); + + // Delta dict prelude follows the 12-byte header. + Assert.That(bytes[12], Is.EqualTo(0x00), "delta_start = 0"); + Assert.That(bytes[13], Is.EqualTo(0x02), "delta_count = symbolDeltaCount, not full Count of 3"); + Assert.That(bytes[14], Is.EqualTo(0x02), "len('us')"); + Assert.That(bytes.AsSpan(15, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("us"))); + Assert.That(bytes[17], Is.EqualTo(0x02), "len('eu')"); + Assert.That(bytes.AsSpan(18, 2).ToArray(), Is.EqualTo(Encoding.UTF8.GetBytes("eu"))); + // "ap" (id 2) lies beyond the prefix; byte 20 is the table-name varint, not another dict entry. + Assert.That(bytes[20], Is.EqualTo((byte)"trades".Length), "table block starts right after the 2-entry prefix"); + } + + [Test] + public void EncodeInto_SelfSufficient_SymbolDeltaCountZero_EmitsEmptyDict() + { + var symbols = new QwpSymbolDictionary(); + symbols.Add("us"); + symbols.Add("eu"); + + var t = new QwpTableBuffer("t"); + t.AppendSymbol("region", 0); + t.At(0); + + var builder = new QwpEncoder.FrameBuilder(4096); + var len = QwpEncoder.EncodeInto(builder, new[] { t }, symbols, + selfSufficient: true, symbolDeltaCount: 0); + var bytes = builder.AsSpan(0, len).ToArray(); + + Assert.That(bytes[12], Is.EqualTo(0x00), "delta_start = 0"); + Assert.That(bytes[13], Is.EqualTo(0x00), "delta_count = 0 → no dict entries emitted"); + Assert.That(bytes[14], Is.EqualTo((byte)1), "table block ('t') starts immediately after the empty dict"); + } + + [Test] + public void Encode_SelfSufficient_TwoTablesInSameFrame_BothEmitFullSchema() + { + var symbols = new QwpSymbolDictionary(); + + var ta = new QwpTableBuffer("a"); + ta.AppendLong("v", 1); + ta.At(0); + + var tb = new QwpTableBuffer("b"); + tb.AppendDouble("v", 2.0); + tb.At(0); + + // Encode once first to push schemas into the cache, then a second time in self-sufficient mode. + QwpEncoder.Encode(new[] { ta, tb }, symbols); + ta.Clear(); + tb.Clear(); + ta.AppendLong("v", 11); + ta.At(1); + tb.AppendDouble("v", 22.0); + tb.At(1); + + var bytes = QwpEncoder.Encode(new[] { ta, tb }, symbols, selfSufficient: true); + + // Layout per table block (no schema_mode / schema_id any more): + // tableName varint(1) + name(1) + rowCount(1) + colCount(1) + // + columnDef("v"): nameLen(1) + "v"(1) + type(1) = 3 + // + designatedTsColDef: nameLen(1)=0 + type(1) = 2 + // + colData("v"): nullFlag(1) + 8 bytes value = 9 + // + colData(""): nullFlag(1) + Gorilla encoding flag(1) + 8 bytes value = 10 + // = 1+1+1+1+3+2+9+10 = 28 bytes per table block. + // The designated-TS column carries a 1-byte Gorilla encoding flag (0x00 uncompressed for a + // single value) ahead of its data, per the always-on FLAG_GORILLA contract. + // First table starts at offset 14 (header 12 + delta 2). The name varint of the second + // table is the byte immediately after the first table block. + const int firstTableStart = 14; + const int tableBlockSize = 28; + Assert.That(bytes[firstTableStart], Is.EqualTo((byte)"a".Length), + "table A name length leads the first table block"); + Assert.That(bytes[firstTableStart + tableBlockSize], Is.EqualTo((byte)"b".Length), + "table B name length leads the second table block (no shared schema-id between them)"); + } + + /// + /// Returns the byte offset at which the first user column's data section starts inside an + /// encoded frame with one user column followed by a designated TS column. + /// + /// + /// Layout up to the first column-data section: + /// header(12) + delta(2) + tableNameLen(1) + tableName + rowCount(1) + colCount(1) + schema(2) + /// + userColDef + tsColDef(1+0+1=2). + /// + private static int FindFirstColumnDataOffset(byte[] frame, int tableNameLen, int userColCount, int userColDefSize) + { + // userColCount unused for the simple single-user-column tests above; kept for future + // multi-column encoder fixtures. + _ = userColCount; + _ = frame; + + // 12 (header) + 2 (delta dict) + 1 (table name varint) + tableNameLen + 1 (rowCount) + 1 (colCount) + // + userColDefSize + 2 (designated TS def: empty name varint=0 + TIMESTAMP) + return 12 + 2 + 1 + tableNameLen + 1 + 1 + userColDefSize + 2; + } + + private static byte[] ConcatBytes(params byte[][] parts) + { + var total = 0; + foreach (var p in parts) total += p.Length; + var result = new byte[total]; + var offset = 0; + foreach (var p in parts) + { + Buffer.BlockCopy(p, 0, result, offset, p.Length); + offset += p.Length; + } + + return result; + } + + private static byte[] LittleEndianInt64(long v) + { + var bytes = new byte[8]; + BinaryPrimitives.WriteInt64LittleEndian(bytes, v); + return bytes; + } + + private static byte[] LittleEndianDouble(double v) + { + var bytes = new byte[8]; + BinaryPrimitives.WriteDoubleLittleEndian(bytes, v); + return bytes; + } + + private static string Hex(byte[] b) + { + return BitConverter.ToString(b).Replace('-', ' '); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpGorillaTests.cs b/src/net-questdb-client-tests/Qwp/QwpGorillaTests.cs new file mode 100644 index 00000000..51d6f9eb --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpGorillaTests.cs @@ -0,0 +1,199 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpGorillaTests +{ + [Test] + public void Encode_EmptyArray_WritesEncodingFlagOnly() + { + var dest = new byte[QwpGorilla.MaxEncodedSize(0)]; + var written = QwpGorilla.Encode(dest, ReadOnlySpan.Empty); + + Assert.That(written, Is.EqualTo(1)); + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingUncompressed)); + } + + [Test] + public void Encode_SingleValue_FallsBackToUncompressed() + { + var input = new long[] { 1_700_000_000L }; + var dest = new byte[QwpGorilla.MaxEncodedSize(1)]; + var written = QwpGorilla.Encode(dest, input); + + Assert.That(written, Is.EqualTo(QwpGorilla.UncompressedSize(1))); + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingUncompressed)); + + AssertRoundTrip(input); + } + + [Test] + public void Encode_TwoValues_FallsBackToUncompressed() + { + // Gorilla mode allows N=2 (no DoDs), but our encoder skips Gorilla under N=3 since the + // overhead of the 17-byte Gorilla header exceeds the uncompressed cost at small N. + var input = new long[] { 1L, 2L }; + var dest = new byte[QwpGorilla.MaxEncodedSize(input.Length)]; + var written = QwpGorilla.Encode(dest, input); + + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingUncompressed)); + Assert.That(written, Is.EqualTo(QwpGorilla.UncompressedSize(2))); + } + + [Test] + public void Encode_ConstantDelta_CompressesToOneBitPerDoD() + { + // Steady-tick timestamps: every DoD is 0 → 1 bit per DoD after the first two. + var input = new long[100]; + for (var i = 0; i < input.Length; i++) + { + input[i] = 1000L + i; + } + + var dest = new byte[QwpGorilla.MaxEncodedSize(input.Length)]; + var written = QwpGorilla.Encode(dest, input); + + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingGorilla)); + // 1 (flag) + 16 (first + second) + ceil(98 × 1 / 8) = 1 + 16 + 13 = 30 bytes. + Assert.That(written, Is.EqualTo(1 + 16 + (98 + 7) / 8)); + + AssertRoundTrip(input); + } + + [Test] + public void Encode_AllBucketBoundaries_RoundTrip() + { + // Build a sequence whose DoD pattern hits every bucket: 0, 7-bit, 9-bit, 12-bit, 32-bit. + var deltas = new long[] { 100, 100, 165 /* DoD = 65, 9-bit bucket */, 165, 165, 422 /* DoD=257, 12-bit */, 422, 5000 /* DoD ~4578, 12-bit */, 5000, 1_000_000 /* DoD~995000, 32-bit */ }; + var ts = new long[deltas.Length + 1]; + ts[0] = 1_000_000L; + for (var i = 0; i < deltas.Length; i++) + { + ts[i + 1] = ts[i] + deltas[i]; + } + + AssertRoundTrip(ts); + } + + [Test] + public void Encode_DoDOverflowsInt32_FallsBackToUncompressed() + { + // First two values are zero, then a step that creates a DoD beyond int32 range. + var ts = new long[] { 0L, 0L, 0L, long.MaxValue / 2 }; + var dest = new byte[QwpGorilla.MaxEncodedSize(ts.Length)]; + var written = QwpGorilla.Encode(dest, ts); + + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingUncompressed)); + Assert.That(written, Is.EqualTo(QwpGorilla.UncompressedSize(ts.Length))); + + AssertRoundTrip(ts); + } + + [Test] + public void Encode_DoDExactlyInt32Max_StaysCompressed() + { + var ts = new long[] { 0L, 0L, int.MaxValue }; + var dest = new byte[QwpGorilla.MaxEncodedSize(ts.Length)]; + var written = QwpGorilla.Encode(dest, ts); + + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingGorilla)); + Assert.That(written, Is.LessThan(QwpGorilla.UncompressedSize(ts.Length))); + + AssertRoundTrip(ts); + } + + [Test] + public void Encode_DoDExactlyInt32Min_StaysCompressed() + { + // delta=0, then delta=int.MinValue → DoD=int.MinValue (just inside the 32-bit signed range). + var ts = new long[] { 0L, 0L, (long)int.MinValue }; + var dest = new byte[QwpGorilla.MaxEncodedSize(ts.Length)]; + var written = QwpGorilla.Encode(dest, ts); + + Assert.That(dest[0], Is.EqualTo(QwpGorilla.EncodingGorilla)); + AssertRoundTrip(ts); + } + + [Test] + public void Encode_NegativeDoDs_RoundTrip() + { + var ts = new long[] { 100L, 200L, 250L /* delta 50, dod -50 */, 280L /* delta 30, dod -20 */, 290L }; + AssertRoundTrip(ts); + } + + [Test] + public void Encode_RandomFuzz_RoundTrip() + { + var rnd = new Random(0xCAFE); + for (var trial = 0; trial < 100; trial++) + { + var n = rnd.Next(3, 200); + var ts = new long[n]; + ts[0] = rnd.NextInt64(0L, 1_000_000_000L); + for (var i = 1; i < n; i++) + { + // Random delta, occasionally large enough to land in higher buckets. + var delta = rnd.Next(0, 10_000); + ts[i] = ts[i - 1] + delta; + } + + AssertRoundTrip(ts); + } + } + + [Test] + public void Decode_UnknownEncodingFlag_Throws() + { + var frame = new byte[] { 0xFF, 0, 0, 0, 0, 0, 0, 0, 0 }; + Assert.Throws(() => + QwpGorilla.Decode(frame, new long[1].AsSpan(), 1)); + } + + [Test] + public void Decode_TruncatedUncompressed_Throws() + { + // Encoding-flag says uncompressed, claims 3 values, but body only has 2 × 8 = 16 bytes. + var frame = new byte[1 + 16]; + frame[0] = QwpGorilla.EncodingUncompressed; + Assert.Throws(() => + QwpGorilla.Decode(frame, new long[3].AsSpan(), 3)); + } + + private static void AssertRoundTrip(long[] input) + { + var dest = new byte[QwpGorilla.MaxEncodedSize(input.Length)]; + var written = QwpGorilla.Encode(dest, input); + + var roundTripped = new long[input.Length]; + QwpGorilla.Decode(dest.AsSpan(0, written), roundTripped.AsSpan(), input.Length); + + Assert.That(roundTripped, Is.EqualTo(input), + $"round-trip failed; encoded {written} bytes for {input.Length} values"); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpHostHealthTrackerTests.cs b/src/net-questdb-client-tests/Qwp/QwpHostHealthTrackerTests.cs new file mode 100644 index 00000000..2a04a679 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpHostHealthTrackerTests.cs @@ -0,0 +1,281 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using NUnit.Framework; +using QuestDB.Qwp; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpHostHealthTrackerTests +{ + [Test] + public void Constructor_RejectsEmptyHosts() + { + Assert.Throws(() => new QwpHostHealthTracker(Array.Empty())); + } + + [Test] + public void Constructor_RejectsNullHosts() + { + Assert.Throws(() => new QwpHostHealthTracker(null!)); + } + + [Test] + public void PickNext_ReturnsAddressOrderWhenAllUnknown() + { + var t = new QwpHostHealthTracker(new[] { "a", "b", "c" }); + Assert.That(t.PickNext(), Is.EqualTo(0)); + t.RecordTransportError(0); + Assert.That(t.PickNext(), Is.EqualTo(1)); + t.RecordTransportError(1); + Assert.That(t.PickNext(), Is.EqualTo(2)); + t.RecordTransportError(2); + Assert.That(t.PickNext(), Is.EqualTo(-1)); + } + + [Test] + public void PickNext_PrefersHealthyOverUnknown() + { + var t = new QwpHostHealthTracker(new[] { "a", "b", "c" }); + // host 1 was healthy in previous round; round flag cleared, classifications kept + t.RecordSuccess(1); + t.BeginRound(forgetClassifications: false); + Assert.That(t.PickNext(), Is.EqualTo(1)); + } + + [Test] + public void PickNext_PrefersUnknownOverTransientReject() + { + var t = new QwpHostHealthTracker(new[] { "a", "b", "c" }); + t.RecordRoleReject(0, transient: true); + t.BeginRound(forgetClassifications: false); + // host 0 is TransientReject; hosts 1 and 2 are Unknown — pick first Unknown + Assert.That(t.PickNext(), Is.EqualTo(1)); + } + + [Test] + public void PickNext_PrefersTransientRejectOverTransportError() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + t.RecordRoleReject(0, transient: true); + t.RecordTransportError(1); + t.BeginRound(forgetClassifications: false); + // Both attempted-flags cleared, classifications kept; TransientReject (1) + // beats TransportError (2) in priority. + Assert.That(t.PickNext(), Is.EqualTo(0)); + } + + [Test] + public void PickNext_PrefersTransportErrorOverTopologyReject() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + t.RecordRoleReject(0, transient: false); // TopologyReject + t.RecordTransportError(1); + t.BeginRound(forgetClassifications: false); + Assert.That(t.PickNext(), Is.EqualTo(1)); + } + + [Test] + public void BeginRound_ForgetClassifications_KeepsLastHealthyAsSticky() + { + var t = new QwpHostHealthTracker(new[] { "a", "b", "c" }); + t.RecordSuccess(0); + t.RecordRoleReject(1, transient: false); + t.RecordTransportError(2); + t.BeginRound(forgetClassifications: true); + Assert.That(t.GetState(0), Is.EqualTo(QwpHostState.Healthy)); + Assert.That(t.GetState(1), Is.EqualTo(QwpHostState.Unknown)); + Assert.That(t.GetState(2), Is.EqualTo(QwpHostState.Unknown)); + Assert.That(t.PickNext(), Is.EqualTo(0)); + } + + [Test] + public void BeginRound_ForgetClassifications_NoHealthy_ResetsAllToUnknown() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + t.RecordRoleReject(0, transient: false); + t.RecordTransportError(1); + t.BeginRound(forgetClassifications: true); + Assert.That(t.GetState(0), Is.EqualTo(QwpHostState.Unknown)); + Assert.That(t.GetState(1), Is.EqualTo(QwpHostState.Unknown)); + } + + [Test] + public void BeginRound_ForgetClassifications_PrefersMostRecentHealthy() + { + var t = new QwpHostHealthTracker(new[] { "a", "b", "c" }); + t.RecordSuccess(0); + t.RecordSuccess(2); + t.BeginRound(forgetClassifications: true); + Assert.That(t.GetState(0), Is.EqualTo(QwpHostState.Unknown)); + Assert.That(t.GetState(2), Is.EqualTo(QwpHostState.Healthy)); + } + + [Test] + public void RecordSuccess_MarksAttemptedAndHealthy() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + t.RecordSuccess(0); + Assert.That(t.GetState(0), Is.EqualTo(QwpHostState.Healthy)); + // attempted-this-round flag is set: should pick host 1 next + Assert.That(t.PickNext(), Is.EqualTo(1)); + } + + [Test] + public void RecordRoleReject_ClassifiesByTransientFlag() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + t.RecordRoleReject(0, transient: true); + t.RecordRoleReject(1, transient: false); + Assert.That(t.GetState(0), Is.EqualTo(QwpHostState.TransientReject)); + Assert.That(t.GetState(1), Is.EqualTo(QwpHostState.TopologyReject)); + } + + [Test] + public void StickyHealthy_AcrossReconnectAfterDrop() + { + // Scenario B: connect succeeded on host 1, connection drops, reconnect. + // BeginRound(false) keeps Healthy classification, host 1 picked first. + var t = new QwpHostHealthTracker(new[] { "a", "b", "c" }); + t.RecordTransportError(0); + t.RecordSuccess(1); + // Connection drops; new reconnect round begins. + t.BeginRound(forgetClassifications: false); + Assert.That(t.PickNext(), Is.EqualTo(1)); + } + + [Test] + public void FullRoundExhaustion_ReturnsMinusOne() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + Assert.That(t.PickNext(), Is.EqualTo(0)); + t.RecordTransportError(0); + Assert.That(t.PickNext(), Is.EqualTo(1)); + t.RecordTransportError(1); + Assert.That(t.PickNext(), Is.EqualTo(-1)); + } + + [Test] + public void ZoneTier_DefaultsToSame_WhenClientZoneUnset() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same)); + Assert.That(t.GetZoneTier(1), Is.EqualTo(QwpZoneTier.Same)); + } + + [Test] + public void ZoneTier_DefaultsToSame_WhenTargetIsPrimary() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: true); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same)); + } + + [Test] + public void ZoneTier_DefaultsToUnknown_WhenZoneSetAndNotPrimary() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Unknown)); + Assert.That(t.GetZoneTier(1), Is.EqualTo(QwpZoneTier.Unknown)); + } + + [Test] + public void RecordZone_MatchingClientZone_MapsToSame() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "US-EAST-1"); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same)); + } + + [Test] + public void RecordZone_DifferentZone_MapsToOther() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "us-west-2"); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Other)); + } + + [Test] + public void RecordZone_NullOrEmpty_IsNoOp() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "us-east-1"); + t.RecordZone(0, null); + t.RecordZone(0, ""); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same)); + } + + [Test] + public void RecordZone_CollapsesToSame_WhenTargetIsPrimary() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: true); + t.RecordZone(0, "us-west-2"); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same)); + } + + [Test] + public void PickNext_PrefersSameZoneOverOtherZone_WithinSameState() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "us-west-2"); + t.RecordZone(1, "us-east-1"); + Assert.That(t.PickNext(), Is.EqualTo(1)); + } + + [Test] + public void PickNext_StateOutranksZone() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "us-west-2"); + t.RecordSuccess(0); + t.RecordZone(1, "us-east-1"); + t.BeginRound(forgetClassifications: false); + Assert.That(t.PickNext(), Is.EqualTo(0)); + } + + [Test] + public void BeginRound_ForgetClassifications_PreservesOnlySameZoneHealthy() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "us-west-2"); + t.RecordZone(1, "us-east-1"); + t.RecordSuccess(0); + t.RecordSuccess(1); + t.BeginRound(forgetClassifications: true); + Assert.That(t.GetState(0), Is.EqualTo(QwpHostState.Unknown)); + Assert.That(t.GetState(1), Is.EqualTo(QwpHostState.Healthy)); + } + + [Test] + public void ZoneTier_PersistsAcrossBeginRound() + { + var t = new QwpHostHealthTracker(new[] { "a", "b" }, clientZone: "us-east-1", targetIsPrimary: false); + t.RecordZone(0, "us-east-1"); + t.RecordZone(1, "us-west-2"); + t.BeginRound(forgetClassifications: true); + Assert.That(t.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same)); + Assert.That(t.GetZoneTier(1), Is.EqualTo(QwpZoneTier.Other)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpMultiHostFailoverTests.cs b/src/net-questdb-client-tests/Qwp/QwpMultiHostFailoverTests.cs new file mode 100644 index 00000000..8c977dba --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpMultiHostFailoverTests.cs @@ -0,0 +1,160 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Net; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Utils; +using dummy_http_server; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpMultiHostFailoverTests +{ + [Test] + public void SenderOptions_AcceptsMultipleAddrForWs() + { + // Previously rejected with "multiple `addr` entries are not supported for ws/wss". + var options = new SenderOptions("ws::addr=h1:9000,h2:9001;"); + Assert.That(options.AddressCount, Is.EqualTo(2)); + Assert.That(options.addresses[0], Is.EqualTo("h1:9000")); + Assert.That(options.addresses[1], Is.EqualTo("h2:9001")); + } + + [Test] + public async Task Transport_421WithRoleHeader_SurfacesAsTypedException() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RoleReplicaName, + }); + await server.StartAsync(); + + var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + var ex = Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + Assert.That(ex!.Role, Is.EqualTo(QwpConstants.RoleReplicaName)); + Assert.That(ex.IsTopological, Is.True); + Assert.That(ex.IsTransient, Is.False); + transport.Dispose(); + } + + [Test] + public async Task Transport_421WithCatchupRole_FlaggedTransient() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RolePrimaryCatchupName, + }); + await server.StartAsync(); + + var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + var ex = Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + Assert.That(ex!.Role, Is.EqualTo(QwpConstants.RolePrimaryCatchupName)); + Assert.That(ex.IsTransient, Is.True); + Assert.That(ex.IsTopological, Is.False); + transport.Dispose(); + } + + [Test] + public async Task Transport_421WithoutRoleHeader_StaysSocketError() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + }); + await server.StartAsync(); + + var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + var ex = Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + Assert.That(ex, Is.Not.InstanceOf()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + transport.Dispose(); + } + + [Test] + public async Task Sender_RotatesPastReplicaToPrimary() + { + await using var replica = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RoleReplicaName, + }); + await replica.StartAsync(); + + await using var primary = new DummyQwpServer(new DummyQwpServerOptions + { + RoleHeader = QwpConstants.RolePrimaryName, + }); + await primary.StartAsync(); + + var connstr = $"ws::addr={replica.Uri.Authority},{primary.Uri.Authority};auto_flush=off;"; + using var sender = Sender.New(connstr); + Assert.That(sender, Is.Not.Null); + } + + [Test] + public async Task Sender_AllReplicas_FailsWithSummary() + { + await using var a = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RoleReplicaName, + }); + await a.StartAsync(); + + await using var b = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.MisdirectedRequest, + RejectUpgradeRoleHeader = QwpConstants.RoleReplicaName, + }); + await b.StartAsync(); + + var connstr = $"ws::addr={a.Uri.Authority},{b.Uri.Authority};auto_flush=off;" + + "reconnect_initial_backoff_millis=10;reconnect_max_backoff_millis=50;reconnect_max_duration_millis=500;"; + var ex = Assert.Throws(() => Sender.New(connstr)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + Assert.That(ex.Message, Does.Contain("all 2 configured endpoint(s)")); + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/QwpResponseTests.cs b/src/net-questdb-client-tests/Qwp/QwpResponseTests.cs new file mode 100644 index 00000000..bf0b26dc --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpResponseTests.cs @@ -0,0 +1,454 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpResponseTests +{ + [Test] + public void StatusCodes_HaveStableByteValues() + { + Assert.That((byte)QwpStatusCode.Ok, Is.EqualTo((byte)0x00)); + Assert.That((byte)QwpStatusCode.DurableAck, Is.EqualTo((byte)0x02)); + Assert.That((byte)QwpStatusCode.SchemaMismatch, Is.EqualTo((byte)0x03)); + Assert.That((byte)QwpStatusCode.ParseError, Is.EqualTo((byte)0x05)); + Assert.That((byte)QwpStatusCode.InternalError, Is.EqualTo((byte)0x06)); + Assert.That((byte)QwpStatusCode.SecurityError, Is.EqualTo((byte)0x08)); + Assert.That((byte)QwpStatusCode.WriteError, Is.EqualTo((byte)0x09)); + Assert.That((byte)QwpStatusCode.Cancelled, Is.EqualTo((byte)0x0A)); + Assert.That((byte)QwpStatusCode.LimitExceeded, Is.EqualTo((byte)0x0B)); + } + + [Test] + public void Parse_OkResponse_ReturnsSequenceAndEmptyMessage() + { + var frame = BuildOk(sequence: 42L); + + var r = QwpResponse.Parse(frame); + + Assert.That(r.IsOk, Is.True); + Assert.That(r.Status, Is.EqualTo(QwpStatusCode.Ok)); + Assert.That(r.Sequence, Is.EqualTo(42L)); + Assert.That(r.Message, Is.EqualTo(string.Empty)); + } + + [Test] + public void Parse_OkResponse_NegativeSequence_RoundTrips() + { + // -1 sentinel is what STATUS_DURABLE_ACK uses, but plain OK can technically carry it too. + var frame = BuildOk(sequence: -1L); + + var r = QwpResponse.Parse(frame); + + Assert.That(r.Sequence, Is.EqualTo(-1L)); + } + + [Test] + public void Parse_OkResponse_TooShort_Throws() + { + var frame = new byte[5]; // status + 4 bytes (truncated) + frame[0] = (byte)QwpStatusCode.Ok; + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_OkResponse_TrailingGarbage_Throws() + { + var frame = new byte[QwpConstants.OffsetTableCountInOkAck + 1]; + // OK status + sequence (zero) + extra byte. + frame[0] = (byte)QwpStatusCode.Ok; + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_ErrorResponse_DecodesMessage() + { + var msg = "table not found: trades"; + var frame = BuildError(QwpStatusCode.WriteError, sequence: 7L, message: msg); + + var r = QwpResponse.Parse(frame); + + Assert.That(r.Status, Is.EqualTo(QwpStatusCode.WriteError)); + Assert.That(r.Sequence, Is.EqualTo(7L)); + Assert.That(r.Message, Is.EqualTo(msg)); + Assert.That(r.IsOk, Is.False); + } + + [Test] + public void Parse_ErrorResponse_EmptyMessage_DecodesCleanly() + { + var frame = BuildError(QwpStatusCode.ParseError, sequence: 0L, message: string.Empty); + + var r = QwpResponse.Parse(frame); + + Assert.That(r.Status, Is.EqualTo(QwpStatusCode.ParseError)); + Assert.That(r.Message, Is.EqualTo(string.Empty)); + } + + [Test] + public void Parse_ErrorResponse_HeaderTruncated_Throws() + { + var frame = new byte[10]; // 1 byte short of the 11-byte header. + frame[0] = (byte)QwpStatusCode.WriteError; + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_ErrorResponse_MessageOversized_Throws() + { + var frame = new byte[QwpConstants.ErrorAckHeaderSize]; + frame[0] = (byte)QwpStatusCode.ParseError; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), QwpConstants.MaxErrorMessageBytes + 1); + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_ErrorResponse_MsgLenLargerThanFrame_Throws() + { + // Header claims 100-byte message but frame only carries 10 bytes of message. + var frame = new byte[QwpConstants.ErrorAckHeaderSize + 10]; + frame[0] = (byte)QwpStatusCode.WriteError; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), 100); + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_ErrorResponse_TrailingGarbage_Throws() + { + // Header says 0-byte message but frame has trailing junk. + var frame = new byte[QwpConstants.ErrorAckHeaderSize + 1]; + frame[0] = (byte)QwpStatusCode.InternalError; + // msg_len = 0 by default zero-init. + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_DurableAck_TruncatedHeader_Throws() + { + var frame = new[] { (byte)QwpStatusCode.DurableAck }; + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_DurableAck_NoEntries_DecodesCleanly() + { + // status (1) + tableCount=0 (2) = 3 bytes. + var frame = new byte[3]; + frame[0] = (byte)QwpStatusCode.DurableAck; + // tableCount little-endian zero (already zeroed). + + var r = QwpResponse.Parse(frame); + + Assert.That(r.IsDurableAck); + Assert.That(r.Sequence, Is.EqualTo(-1L)); + Assert.That(r.TableEntries, Is.Empty); + } + + [Test] + public void Parse_DurableAck_WithEntries_DecodesPerTableSeqTxns() + { + var frame = BuildDurableAck(("trades", 7L), ("orders", 12L)); + + var r = QwpResponse.Parse(frame); + + Assert.That(r.IsDurableAck); + Assert.That(r.Sequence, Is.EqualTo(-1L)); + Assert.That(r.TableEntries.Count, Is.EqualTo(2)); + Assert.That(r.TableEntries[0].TableName, Is.EqualTo("trades")); + Assert.That(r.TableEntries[0].SeqTxn, Is.EqualTo(7L)); + Assert.That(r.TableEntries[1].TableName, Is.EqualTo("orders")); + Assert.That(r.TableEntries[1].SeqTxn, Is.EqualTo(12L)); + } + + [Test] + public void Parse_DurableAck_EmptyTableName_Throws() + { + // Manually build a frame with nameLen=0 to verify rejection. + var frame = new byte[] { + (byte)QwpStatusCode.DurableAck, + 0x01, 0x00, // tableCount = 1 + 0x00, 0x00, // nameLen = 0 (invalid) + 0, 0, 0, 0, 0, 0, 0, 0, // seqTxn + }; + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [Test] + public void Parse_DurableAck_TrailingGarbage_Throws() + { + var frame = BuildDurableAck(("trades", 7L)); + var bloated = new byte[frame.Length + 1]; + Array.Copy(frame, bloated, frame.Length); + Assert.Throws(() => QwpResponse.Parse(bloated)); + } + + [Test] + public void Parse_OkResponse_WithPerTableEntries_DecodesCleanly() + { + var frame = BuildOkWithEntries(sequence: 42L, ("trades", 5L), ("orders", 8L)); + + var r = QwpResponse.Parse(frame); + + Assert.That(r.IsOk); + Assert.That(r.Sequence, Is.EqualTo(42L)); + Assert.That(r.TableEntries.Count, Is.EqualTo(2)); + Assert.That(r.TableEntries[0].TableName, Is.EqualTo("trades")); + Assert.That(r.TableEntries[0].SeqTxn, Is.EqualTo(5L)); + } + + [Test] + public void Parse_OkResponse_WithZeroEntriesViaTableCount_IsAccepted() + { + // status (1) + sequence (8) + tableCount=0 (2) = 11 bytes; valid extended OK with no entries. + var frame = new byte[QwpConstants.OffsetTableCountInOkAck + 2]; + frame[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), 99L); + // tableCount left at zero. + + var r = QwpResponse.Parse(frame); + Assert.That(r.Sequence, Is.EqualTo(99L)); + Assert.That(r.TableEntries, Is.Empty); + } + + [Test] + public void Parse_UnknownStatusCode_ParsesAsErrorFrame() + { + var frame = new byte[QwpConstants.ErrorAckHeaderSize]; + frame[0] = 0xFE; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), 7L); + + var r = QwpResponse.Parse(frame); + Assert.That((byte)r.Status, Is.EqualTo(0xFE)); + Assert.That(r.Sequence, Is.EqualTo(7L)); + Assert.That(r.IsOk, Is.False); + Assert.That(r.IsDurableAck, Is.False); + } + + [Test] + public void Parse_EmptyFrame_Throws() + { + Assert.Throws(() => QwpResponse.Parse(ReadOnlySpan.Empty)); + } + + [Test] + public void ToException_OnError_CarriesStatusAndSequence() + { + var r = new QwpResponse(QwpStatusCode.SchemaMismatch, 42L, "type clash", Array.Empty()); + var ex = r.ToException(); + + Assert.That(ex.Status, Is.EqualTo(QwpStatusCode.SchemaMismatch)); + Assert.That(ex.Sequence, Is.EqualTo(42L)); + Assert.That(ex.Message, Does.Contain("type clash")); + } + + [Test] + public void RoundTrip_AllErrorStatusCodes() + { + foreach (var status in new[] + { + QwpStatusCode.SchemaMismatch, + QwpStatusCode.ParseError, + QwpStatusCode.InternalError, + QwpStatusCode.SecurityError, + QwpStatusCode.WriteError, + }) + { + var msg = $"error from {status}"; + var frame = BuildError(status, sequence: 99L, message: msg); + var r = QwpResponse.Parse(frame); + + Assert.That(r.Status, Is.EqualTo(status)); + Assert.That(r.Sequence, Is.EqualTo(99L)); + Assert.That(r.Message, Is.EqualTo(msg)); + } + } + + private static byte[] BuildOk(long sequence) + { + var bytes = new byte[QwpConstants.OffsetTableCountInOkAck + 2]; + bytes[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(9, 2), 0); + return bytes; + } + + private static byte[] BuildOkWithEntries(long sequence, params (string Name, long SeqTxn)[] entries) + { + // status (1) + sequence (8) + tableCount (2) + entries + var size = QwpConstants.OffsetTableCountInOkAck + 2; + foreach (var e in entries) + { + size += 2 + Encoding.UTF8.GetByteCount(e.Name) + 8; + } + + var frame = new byte[size]; + frame[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCountInOkAck, 2), (ushort)entries.Length); + + var pos = QwpConstants.OffsetTableCountInOkAck + 2; + foreach (var e in entries) + { + var nameBytes = Encoding.UTF8.GetBytes(e.Name); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(frame, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(pos, 8), e.SeqTxn); + pos += 8; + } + + return frame; + } + + private static byte[] BuildDurableAck(params (string Name, long SeqTxn)[] entries) + { + // status (1) + tableCount (2) + entries + var size = 3; + foreach (var e in entries) + { + size += 2 + Encoding.UTF8.GetByteCount(e.Name) + 8; + } + + var frame = new byte[size]; + frame[0] = (byte)QwpStatusCode.DurableAck; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(1, 2), (ushort)entries.Length); + + var pos = 3; + foreach (var e in entries) + { + var nameBytes = Encoding.UTF8.GetBytes(e.Name); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(frame, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(pos, 8), e.SeqTxn); + pos += 8; + } + + return frame; + } + + [Test] + public void Parse_ErrorResponse_InvalidUtf8Message_DecodesLossily() + { + // 0xC3 0x28 is a malformed two-byte sequence. The free-form diagnostic message decodes + // leniently — a recoverable server error must not be escalated into a terminal UTF-8 + // protocol error just because its message text is slightly malformed. + var msgBytes = new byte[] { 0xC3, 0x28 }; + var frame = new byte[QwpConstants.ErrorAckHeaderSize + msgBytes.Length]; + frame[0] = (byte)QwpStatusCode.WriteError; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), 5L); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), (ushort)msgBytes.Length); + msgBytes.CopyTo(frame, QwpConstants.ErrorAckHeaderSize); + + var r = QwpResponse.Parse(frame); + Assert.That(r.Status, Is.EqualTo(QwpStatusCode.WriteError)); + Assert.That(r.Sequence, Is.EqualTo(5L)); + Assert.That(r.Message, Does.Contain("�")); + } + + [Test] + public void Parse_IngestResponse_CancelledStatus_RejectedAsProtocolError() + { + // Cancelled (0x0A) is egress-only (QUERY_ERROR). It must not appear on the ingest reply + // channel; rejecting it loudly catches server bugs instead of masking them as generic errors. + var frame = new byte[QwpConstants.ErrorAckHeaderSize]; + frame[0] = (byte)QwpStatusCode.Cancelled; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), 0L); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), 0); + + var ex = Assert.Throws(() => QwpResponse.Parse(frame)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + } + + [Test] + public void Parse_IngestResponse_LimitExceededStatus_RejectedAsProtocolError() + { + var frame = new byte[QwpConstants.ErrorAckHeaderSize]; + frame[0] = (byte)QwpStatusCode.LimitExceeded; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), 0L); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), 0); + + var ex = Assert.Throws(() => QwpResponse.Parse(frame)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + } + + [Test] + public void Parse_IngestResponse_GenuinelyUnknownStatusByte_StillParsedForwardCompat() + { + // Truly unknown bytes (not the known-egress-only 0x0A/0x0B) must keep flowing through + // ParseError so future server-side status codes don't need a client update — the engine's + // error classifier maps them to category=Unknown which always halts, no data corruption risk. + var frame = new byte[QwpConstants.ErrorAckHeaderSize]; + frame[0] = 0x7F; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), 0L); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), 0); + + var r = QwpResponse.Parse(frame); + Assert.That((byte)r.Status, Is.EqualTo(0x7F)); + } + + [Test] + public void Parse_DurableAck_InvalidUtf8TableName_Throws() + { + // status (1) + tableCount (2) + entry: nameLen (2) + name (2 bytes invalid UTF-8) + seqTxn (8) + var nameBytes = new byte[] { 0xC3, 0x28 }; + var frame = new byte[3 + 2 + nameBytes.Length + 8]; + frame[0] = (byte)QwpStatusCode.DurableAck; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(1, 2), 1); + var pos = 3; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(frame, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(pos, 8), 7L); + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + private static byte[] BuildError(QwpStatusCode status, long sequence, string message) + { + var msgBytes = Encoding.UTF8.GetBytes(message); + var frame = new byte[QwpConstants.ErrorAckHeaderSize + msgBytes.Length]; + frame[0] = (byte)status; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), (ushort)msgBytes.Length); + msgBytes.CopyTo(frame, QwpConstants.ErrorAckHeaderSize); + return frame; + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpSymbolDictionaryTests.cs b/src/net-questdb-client-tests/Qwp/QwpSymbolDictionaryTests.cs new file mode 100644 index 00000000..e33c48a6 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpSymbolDictionaryTests.cs @@ -0,0 +1,155 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpSymbolDictionaryTests +{ + [Test] + public void Add_AssignsAscendingIds() + { + var d = new QwpSymbolDictionary(); + Assert.That(d.Add("us"), Is.EqualTo(0)); + Assert.That(d.Add("eu"), Is.EqualTo(1)); + Assert.That(d.Add("jp"), Is.EqualTo(2)); + Assert.That(d.Count, Is.EqualTo(3)); + } + + [Test] + public void Add_RepeatedValue_ReturnsSameId() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); + d.Add("eu"); + Assert.That(d.Add("us"), Is.EqualTo(0)); + Assert.That(d.Count, Is.EqualTo(2)); + } + + [Test] + public void DeltaIsAllEntriesUntilFirstCommit() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); + d.Add("eu"); + + Assert.That(d.DeltaStart, Is.Zero); + Assert.That(d.DeltaCount, Is.EqualTo(2)); + Assert.That(d.GetSymbol(0), Is.EqualTo("us")); + Assert.That(d.GetSymbol(1), Is.EqualTo("eu")); + } + + [Test] + public void Commit_AdvancesWatermarkAndClearsDelta() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); + d.Add("eu"); + d.Commit(); + + Assert.That(d.CommittedCount, Is.EqualTo(2)); + Assert.That(d.DeltaCount, Is.Zero); + Assert.That(d.DeltaStart, Is.EqualTo(2)); + } + + [Test] + public void DeltaContainsOnlyNewEntriesAfterCommit() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); + d.Commit(); + d.Add("eu"); + d.Add("jp"); + + Assert.That(d.DeltaStart, Is.EqualTo(1)); + Assert.That(d.DeltaCount, Is.EqualTo(2)); + Assert.That(d.GetSymbol(d.DeltaStart), Is.EqualTo("eu")); + Assert.That(d.GetSymbol(d.DeltaStart + 1), Is.EqualTo("jp")); + } + + [Test] + public void Rollback_RevertsUncommittedEntries() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); + d.Commit(); + d.Add("eu"); + d.Add("jp"); + Assert.That(d.Count, Is.EqualTo(3)); + + d.Rollback(); + + Assert.That(d.Count, Is.EqualTo(1), "only committed entries remain"); + Assert.That(d.DeltaCount, Is.Zero); + // After rollback, "eu" is reissued at id 1 (the slot it had before). + Assert.That(d.Add("eu"), Is.EqualTo(1)); + } + + [Test] + public void RollbackTo_DropsOnlyEntriesAboveTarget() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); d.Add("eu"); + d.Commit(); + var checkpoint = d.Count; + d.Add("jp"); d.Add("br"); d.Add("au"); + Assert.That(d.Count, Is.EqualTo(5)); + + d.RollbackTo(checkpoint); + + Assert.That(d.Count, Is.EqualTo(checkpoint)); + Assert.That(d.DeltaCount, Is.Zero); + Assert.That(d.Add("jp"), Is.EqualTo(checkpoint), + "rolled-back ids are reissued from the same slot"); + } + + [Test] + public void RollbackTo_BelowCommittedWatermark_Throws() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); d.Add("eu"); + d.Commit(); + + Assert.Throws(() => d.RollbackTo(0)); + } + + [Test] + public void Reset_ClearsEverything() + { + var d = new QwpSymbolDictionary(); + d.Add("us"); + d.Commit(); + d.Add("eu"); + + d.Reset(); + + Assert.That(d.Count, Is.Zero); + Assert.That(d.CommittedCount, Is.Zero); + Assert.That(d.DeltaCount, Is.Zero); + Assert.That(d.Add("us"), Is.EqualTo(0)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpTableBufferTests.cs b/src/net-questdb-client-tests/Qwp/QwpTableBufferTests.cs new file mode 100644 index 00000000..9d674064 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpTableBufferTests.cs @@ -0,0 +1,491 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpTableBufferTests +{ + [Test] + public void NewBuffer_EmptyTableName_Throws() + { + Assert.Throws(() => new QwpTableBuffer("")); + } + + [Test] + public void NewBuffer_OverlongName_Throws() + { + var name = new string('x', QwpConstants.MaxNameLengthBytes + 1); + Assert.Throws(() => new QwpTableBuffer(name)); + } + + [Test] + public void NewBuffer_TableNameWithLoneSurrogate_ThrowsBeforeEmit() + { + // \uD800 is an unpaired high surrogate; lenient Encoding.UTF8 would replace with U+FFFD + // and slip the size-check, then explode at encoder emit time. Strict path fails up-front. + var name = "t\uD800bad"; + var ex = Assert.Throws(() => new QwpTableBuffer(name)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.InvalidName)); + } + + [Test] + public void AppendColumn_NameWithLoneSurrogate_ThrowsBeforeEmit() + { + var t = new QwpTableBuffer("trades"); + var name = "col\uDC00"; + var ex = Assert.Throws(() => t.AppendDouble(name, 1.0)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.InvalidName)); + } + + [Test] + public void HappyPath_SingleRow_ProducesExpectedColumns() + { + var t = new QwpTableBuffer("trades"); + t.AppendVarchar("ticker", "ETH-USD"); + t.AppendDouble("price", 2615.54); + t.AppendLong("volume", 1234); + t.At(1_700_000_000_000_000L); + + Assert.That(t.RowCount, Is.EqualTo(1)); + Assert.That(t.Columns.Count, Is.EqualTo(3), "3 user columns; designated TS is separate"); + Assert.That(t.TotalColumnCount, Is.EqualTo(4)); + Assert.That(t.DesignatedTimestampColumn, Is.Not.Null); + Assert.That(t.HasPendingRow, Is.False); + + var ticker = t.Columns[0]; + var price = t.Columns[1]; + var volume = t.Columns[2]; + var ts = t.DesignatedTimestampColumn!; + + Assert.That(ticker.Name, Is.EqualTo("ticker")); + Assert.That(ticker.TypeCode, Is.EqualTo(QwpTypeCode.Varchar)); + Assert.That(price.TypeCode, Is.EqualTo(QwpTypeCode.Double)); + Assert.That(volume.TypeCode, Is.EqualTo(QwpTypeCode.Long)); + Assert.That(ts.Name, Is.EqualTo(""), "designated TS column has empty name"); + Assert.That(ts.TypeCode, Is.EqualTo(QwpTypeCode.Timestamp)); + } + + [Test] + public void At_UntouchedColumnInSubsequentRow_GetsNullPadded() + { + var t = new QwpTableBuffer("t"); + + t.AppendDouble("price", 1.0); + t.AppendLong("volume", 100); + t.At(0); + + // Second row only sets price; volume should null-pad. + t.AppendDouble("price", 2.0); + t.At(1); + + Assert.That(t.RowCount, Is.EqualTo(2)); + var volume = t.Columns[1]; + Assert.That(volume.RowCount, Is.EqualTo(2)); + Assert.That(volume.NullCount, Is.EqualTo(1)); + Assert.That(volume.NonNullCount, Is.EqualTo(1)); + // Row 1 (index 1) is null → bit 1 set → 0b00000010 = 0x02. + Assert.That(volume.NullBitmap![0], Is.EqualTo(0x02)); + } + + [Test] + public void NewColumn_MidBatch_BackfillsLeadingNulls() + { + var t = new QwpTableBuffer("t"); + + t.AppendDouble("price", 1.0); + t.At(0); + t.AppendDouble("price", 2.0); + t.At(1); + + // Add a new column on row 3 (zero-based row 2); it must back-fill the first two rows as nulls. + t.AppendDouble("price", 3.0); + t.AppendLong("volume", 999); + t.At(2); + + Assert.That(t.Columns.Count, Is.EqualTo(2), "user columns: price + volume"); + Assert.That(t.TotalColumnCount, Is.EqualTo(3), "user columns + designated TS"); + + var volume = t.Columns[1]; + Assert.That(volume.Name, Is.EqualTo("volume")); + Assert.That(volume.RowCount, Is.EqualTo(3)); + Assert.That(volume.NullCount, Is.EqualTo(2), "rows 0 and 1 null-padded"); + Assert.That(volume.NonNullCount, Is.EqualTo(1)); + // Bits 0 and 1 set → 0b00000011 = 0x03. + Assert.That(volume.NullBitmap![0], Is.EqualTo(0x03)); + } + + [Test] + public void AppendBeforeAt_AndDoubleAt_AdvancesRowCorrectly() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("a", 10); + t.At(100); + Assert.That(t.RowCount, Is.EqualTo(1)); + + t.AppendLong("a", 20); + t.At(200); + Assert.That(t.RowCount, Is.EqualTo(2)); + + var a = t.Columns[0]; + Assert.That(a.NonNullCount, Is.EqualTo(2)); + Assert.That(a.FixedLen, Is.EqualTo(16)); + } + + [Test] + public void At_NoColumnsTouched_StillCommitsRowWithDesignatedTimestamp() + { + var t = new QwpTableBuffer("ping"); + t.At(123_456L); + + Assert.That(t.RowCount, Is.EqualTo(1)); + Assert.That(t.Columns.Count, Is.Zero, "no user columns"); + Assert.That(t.TotalColumnCount, Is.EqualTo(1), "only the designated TS exists"); + Assert.That(t.DesignatedTimestampColumn, Is.Not.Null); + Assert.That(t.DesignatedTimestampColumn!.Name, Is.EqualTo("")); + Assert.That(t.DesignatedTimestampColumn.NonNullCount, Is.EqualTo(1)); + } + + [Test] + public void AtNanos_UsesTimestampNanosTypeCode() + { + var t = new QwpTableBuffer("t"); + t.AtNanos(123_456_789_000L); + + Assert.That(t.DesignatedTimestampColumn!.TypeCode, Is.EqualTo(QwpTypeCode.TimestampNanos)); + } + + [Test] + public void At_AfterAtNanos_TypeMismatchThrows() + { + var t = new QwpTableBuffer("t"); + t.AtNanos(1); + Assert.Throws(() => t.At(1)); + } + + [Test] + public void AppendLong_ThenAppendDouble_OnSameColumn_Throws() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + Assert.Throws(() => t.AppendDouble("x", 1.0)); + } + + [Test] + public void AppendEmptyName_Throws() + { + var t = new QwpTableBuffer("t"); + Assert.Throws(() => t.AppendLong("", 1)); + } + + [Test] + public void AppendOverlongColumnName_Throws() + { + var t = new QwpTableBuffer("t"); + var name = new string('y', QwpConstants.MaxNameLengthBytes + 1); + Assert.Throws(() => t.AppendLong(name, 1)); + } + + [Test] + public void AppendSameColumnTwice_InOneRow_FirstValueWins() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + Assert.DoesNotThrow(() => t.AppendLong("x", 2)); + t.At(0); + var x = t.Columns[0]; + Assert.That(x.RowCount, Is.EqualTo(1)); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(x.FixedData!.AsSpan(0, 8)), + Is.EqualTo(1L)); + } + + [Test] + public void AppendSameColumnTwice_DifferentTypes_Throws() + { + var t = new QwpTableBuffer("t"); + t.AppendBool("flag", true); + t.At(0); + Assert.Throws(() => t.AppendLong("flag", 1)); + } + + [Test] + public void DoubleAppend_InOneRow_FirstValueWins() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("a", 10); + t.At(1_000); + + t.AppendLong("a", 20); + t.AppendLong("b", 30); + Assert.DoesNotThrow(() => t.AppendLong("a", 999)); + t.At(2_000); + + Assert.That(t.RowCount, Is.EqualTo(2)); + var aRow1 = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian( + t.Columns[0].FixedData!.AsSpan(0, 8)); + var aRow2 = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian( + t.Columns[0].FixedData!.AsSpan(8, 8)); + Assert.That(aRow1, Is.EqualTo(10L)); + Assert.That(aRow2, Is.EqualTo(20L)); + } + + [Test] + public void DoubleAppend_OnFreshlyAddedColumn_KeepsFirstValue() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("base", 1); + t.At(1_000); + + t.AppendLong("base", 2); + t.AppendLong("fresh", 5); + Assert.DoesNotThrow(() => t.AppendLong("fresh", 6)); + t.At(2_000); + + Assert.That(t.Columns.Count, Is.EqualTo(2)); + var fresh = t.Columns[1]; + Assert.That(fresh.Name, Is.EqualTo("fresh")); + Assert.That(fresh.NonNullCount, Is.EqualTo(1)); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian( + fresh.FixedData!.AsSpan(0, 8)), Is.EqualTo(5L)); + } + + [Test] + public void WideRow_512Columns_RoundTrips() + { + const int columnCount = 512; + var t = new QwpTableBuffer("wide"); + for (var i = 0; i < columnCount; i++) + { + t.AppendLong($"c{i}", i); + } + t.At(1_700_000_000_000_000L); + + Assert.That(t.RowCount, Is.EqualTo(1)); + Assert.That(t.Columns.Count, Is.EqualTo(columnCount)); + for (var i = 0; i < columnCount; i++) + { + Assert.That(t.Columns[i].Name, Is.EqualTo($"c{i}")); + } + } + + [Test] + public void EmptyVarchar_AcceptedAndPreservesLength() + { + var t = new QwpTableBuffer("t"); + t.AppendVarchar("v", ReadOnlySpan.Empty); + t.At(1_000); + + Assert.That(t.RowCount, Is.EqualTo(1)); + Assert.That(t.Columns[0].TypeCode, Is.EqualTo(QwpTypeCode.Varchar)); + } + + [Test] + public void Clear_WithPendingRow_RollsBackBeforeWiping() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("base", 1); + t.At(1_000); + + t.AppendLong("base", 2); + t.AppendLong("fresh", 5); + Assert.That(t.HasPendingRow, Is.True); + Assert.That(t.Columns.Count, Is.EqualTo(2)); + + t.Clear(); + + Assert.That(t.HasPendingRow, Is.False); + Assert.That(t.RowCount, Is.EqualTo(0)); + Assert.That(t.Columns.Count, Is.EqualTo(1), "freshly added column from the cancelled row must not survive Clear"); + Assert.That(t.Columns[0].Name, Is.EqualTo("base")); + } + + [Test] + public void GetBufferedBytes_SumsAllColumnsIncludingDesignatedTimestamp() + { + var t = new QwpTableBuffer("t"); + Assert.That(t.GetBufferedBytes(), Is.Zero); + + t.AppendVarchar("ticker", "ETH"); + t.AppendLong("volume", 1234); + t.At(1_700_000_000_000_000L); + + // ticker (varchar) : 3 data bytes + 2 offset slots × uint = 11 + // volume (long) : 8 + // designated TS : 8 + var ticker = t.Columns[0].BufferedBytes; + var volume = t.Columns[1].BufferedBytes; + var ts = t.DesignatedTimestampColumn!.BufferedBytes; + + Assert.That(ticker, Is.EqualTo(3 + 2 * sizeof(uint))); + Assert.That(volume, Is.EqualTo(8)); + Assert.That(ts, Is.EqualTo(8)); + Assert.That(t.GetBufferedBytes(), Is.EqualTo(ticker + volume + ts)); + } + + [Test] + public void ColumnName_CaseInsensitive_FooAndfoo_ResolveToSameColumn() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("Foo", 1); + // "foo" differs only in case; first-value-wins means this is silently dropped, + // but it must NOT spawn a second column. + t.AppendLong("foo", 2); + t.At(0); + + Assert.That(t.Columns.Count, Is.EqualTo(1), "case-only difference must not create a second column"); + Assert.That(t.Columns[0].Name, Is.EqualTo("Foo"), "the first-seen casing is retained"); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian( + t.Columns[0].FixedData!.AsSpan(0, 8)), Is.EqualTo(1L)); + } + + [Test] + public void ColumnName_CaseInsensitive_AcrossRows_KeepsSingleColumn() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("Price", 10); + t.At(0); + // Subsequent rows touch the index via the AlternateLookup span path on net9+. + t.AppendLong("PRICE", 20); + t.At(1); + t.AppendLong("price", 30); + t.At(2); + + Assert.That(t.Columns.Count, Is.EqualTo(1), "all casings must map to the one column"); + var price = t.Columns[0]; + Assert.That(price.RowCount, Is.EqualTo(3)); + Assert.That(price.NullCount, Is.Zero, "every row resolved the same column, so none null-padded"); + } + + [TestCase("varchar")] + [TestCase("symbol")] + [TestCase("bool")] + [TestCase("decimal")] + [TestCase("geohash")] + [TestCase("doublearray")] + [TestCase("longarray")] + public void DoubleAppend_PerType_FirstValueWins(string kind) + { + var t = new QwpTableBuffer("t"); + Append(t, "c", kind); + t.At(1_000); + Assert.That(t.RowCount, Is.EqualTo(1)); + + Append(t, "c", kind); + Assert.DoesNotThrow(() => Append(t, "c", kind)); + t.At(2_000); + + Assert.That(t.RowCount, Is.EqualTo(2)); + Assert.That(t.HasPendingRow, Is.False); + } + + [Test] + public void FailedAppendMidRow_RollsBackRow_NextRowCommitsClean() + { + var t = new QwpTableBuffer("t"); + // Row 0 establishes three long columns. + t.AppendLong("a", 1); + t.AppendLong("b", 2); + t.AppendLong("c", 3); + t.At(1_000); + + // Row 1: set a and b, then a type-mismatched append on c aborts the whole row. + t.AppendLong("a", 10); + t.AppendLong("b", 20); + Assert.Throws(() => t.AppendDouble("c", 9.9)); + Assert.That(t.HasPendingRow, Is.False, "the failed row must be fully cancelled"); + Assert.That(t.RowCount, Is.EqualTo(1), "no partial row may be committed"); + + // Retry row 1 cleanly. + t.AppendLong("a", 11); + t.AppendLong("b", 21); + t.AppendLong("c", 31); + t.At(2_000); + + Assert.That(t.RowCount, Is.EqualTo(2)); + Assert.That(t.Columns.Count, Is.EqualTo(3), "no orphan column from the failed row"); + + var a = t.Columns[0]; + var b = t.Columns[1]; + var c = t.Columns[2]; + Assert.That(a.NullCount, Is.Zero); + Assert.That(b.NullCount, Is.Zero); + Assert.That(c.NullCount, Is.Zero); + // Row 1 carries the retry values, not the abandoned 10/20. + Assert.That(ReadLong(a, 1), Is.EqualTo(11L)); + Assert.That(ReadLong(b, 1), Is.EqualTo(21L)); + Assert.That(ReadLong(c, 1), Is.EqualTo(31L)); + } + + [Test] + public void FailedAppendMidRow_DropsColumnAddedInThatFailedRow() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("a", 1); + t.AppendLong("b", 2); + t.At(1_000); + + // Row 1: touch a, add a brand-new column d, then fail on a type-mismatched b. + t.AppendLong("a", 10); + t.AppendLong("d", 99); + Assert.Throws(() => t.AppendDouble("b", 1.5)); + Assert.That(t.HasPendingRow, Is.False); + + // d existed only in the cancelled row, so it must not survive. + Assert.That(t.Columns.Count, Is.EqualTo(2), "column added in the failed row must be dropped"); + Assert.That(t.Columns[0].Name, Is.EqualTo("a")); + Assert.That(t.Columns[1].Name, Is.EqualTo("b")); + + // The buffer is still usable: a clean row 1 commits. + t.AppendLong("a", 11); + t.AppendLong("b", 21); + t.At(2_000); + Assert.That(t.RowCount, Is.EqualTo(2)); + Assert.That(t.Columns[0].NullCount, Is.Zero); + Assert.That(t.Columns[1].NullCount, Is.Zero); + } + + private static long ReadLong(QwpColumn col, int row) => + System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(col.FixedData!.AsSpan(row * 8, 8)); + + private static void Append(QwpTableBuffer t, string col, string kind) + { + switch (kind) + { + case "varchar": t.AppendVarchar(col, "x"); break; + case "symbol": t.AppendSymbol(col, 0); break; + case "bool": t.AppendBool(col, true); break; + case "decimal": t.AppendDecimal128(col, 1.5m); break; + case "geohash": t.AppendGeohash(col, 0xAB, 8); break; + case "doublearray": t.AppendDoubleArray(col, new double[] { 1, 2 }, new[] { 2 }); break; + case "longarray": t.AppendLongArray(col, new long[] { 1, 2 }, new[] { 2 }); break; + default: throw new ArgumentException(kind); + } + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpVarintTests.cs b/src/net-questdb-client-tests/Qwp/QwpVarintTests.cs new file mode 100644 index 00000000..a85188b5 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpVarintTests.cs @@ -0,0 +1,170 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using NUnit.Framework; +using QuestDB.Qwp; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpVarintTests +{ + private static readonly (ulong Value, byte[] Encoded)[] SpecVectors = + { + (0, new byte[] { 0x00 }), + (1, new byte[] { 0x01 }), + (127, new byte[] { 0x7F }), + (128, new byte[] { 0x80, 0x01 }), + (255, new byte[] { 0xFF, 0x01 }), + (300, new byte[] { 0xAC, 0x02 }), + (16384, new byte[] { 0x80, 0x80, 0x01 }), + }; + + [Test] + public void Write_SpecVectors_ProducesExactBytes() + { + Span buffer = stackalloc byte[QwpVarint.MaxBytes]; + foreach (var (value, encoded) in SpecVectors) + { + var written = QwpVarint.Write(buffer, value); + + Assert.That(written, Is.EqualTo(encoded.Length), $"byte count for value {value}"); + Assert.That(buffer[..written].ToArray(), Is.EqualTo(encoded), $"bytes for value {value}"); + } + } + + [Test] + public void Read_SpecVectors_DecodesExactValues() + { + foreach (var (value, encoded) in SpecVectors) + { + var decoded = QwpVarint.Read(encoded, out var bytesRead); + + Assert.That(decoded, Is.EqualTo(value), $"value for bytes {BitConverter.ToString(encoded)}"); + Assert.That(bytesRead, Is.EqualTo(encoded.Length), $"bytes read for value {value}"); + } + } + + [Test] + public void GetByteCount_MatchesWrite() + { + foreach (var (value, encoded) in SpecVectors) + { + Assert.That(QwpVarint.GetByteCount(value), Is.EqualTo(encoded.Length), $"value {value}"); + } + } + + [Test] + public void RoundTrip_PowerOfTwoBoundaries_PreservesValue() + { + for (var bit = 0; bit < 64; bit++) + { + var value = 1ul << bit; + AssertRoundTrip(value); + if (value > 0) AssertRoundTrip(value - 1); + } + } + + [Test] + public void RoundTrip_MaxUlong_Uses10Bytes() + { + Span buffer = stackalloc byte[QwpVarint.MaxBytes]; + var written = QwpVarint.Write(buffer, ulong.MaxValue); + + Assert.That(written, Is.EqualTo(QwpVarint.MaxBytes)); + Assert.That(QwpVarint.GetByteCount(ulong.MaxValue), Is.EqualTo(QwpVarint.MaxBytes)); + + var decoded = QwpVarint.Read(buffer[..written], out var read); + Assert.That(decoded, Is.EqualTo(ulong.MaxValue)); + Assert.That(read, Is.EqualTo(written)); + } + + [Test] + public void Read_TruncatedInput_Throws() + { + // 0x80 has the continuation bit set but no follow-up byte. + var truncated = new byte[] { 0x80 }; + Assert.Throws(() => QwpVarint.Read(truncated, out _)); + } + + [Test] + public void Read_OverlongInput_Throws() + { + // 11 bytes of continuation bytes — exceeds the 10-byte limit. + var overlong = new byte[QwpVarint.MaxBytes + 1]; + Array.Fill(overlong, (byte)0x80); + Assert.Throws(() => QwpVarint.Read(overlong, out _)); + } + + [Test] + public void Write_DestinationTooSmall_Throws() + { + var buffer = new byte[1]; + Assert.Throws(() => QwpVarint.Write(buffer, 128)); + } + + [Test] + public void Read_TrailingBytes_AreNotConsumed() + { + // 300 encodes to 0xAC 0x02 (2 bytes); 0xFF after must remain untouched. + var input = new byte[] { 0xAC, 0x02, 0xFF }; + var decoded = QwpVarint.Read(input, out var bytesRead); + + Assert.That(decoded, Is.EqualTo(300u)); + Assert.That(bytesRead, Is.EqualTo(2)); + } + + [Test] + public void RoundTrip_RandomFuzz_PreservesValue() + { + var rnd = new Random(0xCAFE); + Span buffer = stackalloc byte[QwpVarint.MaxBytes]; + Span randBytes = stackalloc byte[8]; + + for (var i = 0; i < 10_000; i++) + { + rnd.NextBytes(randBytes); + var value = BinaryPrimitives.ReadUInt64LittleEndian(randBytes); + + var written = QwpVarint.Write(buffer, value); + var decoded = QwpVarint.Read(buffer[..written], out var read); + + Assert.That(decoded, Is.EqualTo(value)); + Assert.That(read, Is.EqualTo(written)); + } + } + + private static void AssertRoundTrip(ulong value) + { + Span buffer = stackalloc byte[QwpVarint.MaxBytes]; + var written = QwpVarint.Write(buffer, value); + var decoded = QwpVarint.Read(buffer[..written], out var read); + + Assert.That(decoded, Is.EqualTo(value), $"value {value}"); + Assert.That(read, Is.EqualTo(written), $"bytes match for value {value}"); + Assert.That(written, Is.EqualTo(QwpVarint.GetByteCount(value)), $"GetByteCount for value {value}"); + } +} diff --git a/src/net-questdb-client-tests/Qwp/QwpWebSocketSenderTests.cs b/src/net-questdb-client-tests/Qwp/QwpWebSocketSenderTests.cs new file mode 100644 index 00000000..85c4246c --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpWebSocketSenderTests.cs @@ -0,0 +1,1881 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Senders; +using QuestDB.Utils; +using dummy_http_server; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpWebSocketSenderTests +{ + [Test] + public async Task Send_RowInProgress_Throws() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("x", 1L); // no At()/AtNow() — row uncommitted + var ex = Assert.Throws(() => sender.Send()); + Assert.That(ex!.Message, Does.Contain("row in progress")); + } + + [Test] + public async Task SendAsync_RowInProgress_Throws() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("x", 1L); + Assert.ThrowsAsync(async () => await sender.SendAsync()); + } + + [Test] + public async Task EndToEnd_SingleRow_ServerReceivesValidQwpFrame() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("trades") + .Symbol("ticker", "ETH-USD") + .Column("price", 2615.54) + .Column("volume", 1234L) + .At(new DateTime(2026, 4, 28, 12, 0, 0, DateTimeKind.Utc)); + + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1)); + + var frame = server.ReceivedFrames.First(); + + // Header sanity. + Assert.That(BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(0, 4)), Is.EqualTo(QwpConstants.Magic)); + Assert.That(frame[QwpConstants.OffsetVersion], Is.EqualTo(QwpConstants.SupportedVersion)); + Assert.That(frame[QwpConstants.OffsetFlags], + Is.EqualTo((byte)(QwpConstants.FlagDeltaSymbolDict | QwpConstants.FlagGorilla))); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2)), + Is.EqualTo(1)); + + // Symbol delta dict carries one entry "ETH-USD". + var deltaStart = frame[12]; + var deltaCount = frame[13]; + Assert.That(deltaStart, Is.EqualTo(0)); + Assert.That(deltaCount, Is.EqualTo(1)); + var symLen = frame[14]; + Assert.That(symLen, Is.EqualTo(7)); + Assert.That(System.Text.Encoding.UTF8.GetString(frame.AsSpan(15, 7)), Is.EqualTo("ETH-USD")); + } + + [Test] + public async Task Symbol_LoneSurrogateValue_DoesNotWedgeSender() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + // "\uD800" is a lone high surrogate — invalid UTF-16. Building/committing/flushing the bad + // row is expected to fail at some stage; tolerate a throw wherever the value is rejected. + try + { + sender.Table("t").Symbol("sym", "x\uD800").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + } + catch + { + // expected: the malformed value is rejected + } + + // The malformed value must not have wedged the sender: a well-formed row must still flush. + sender.Table("t").Symbol("sym", "ok").Column("v", 2L).At(DateTime.UtcNow); + Assert.DoesNotThrow(() => sender.Send(), + "a valid row sent after a malformed symbol value still throws — the sender is wedged (data loss)"); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(server.ReceivedFrames.Count, Is.GreaterThanOrEqualTo(1), + "the valid row never reached the server"); + } + + // A malformed symbol value must surface as the library's IngressError (as a malformed table / + // column name already does), not as a raw System.Text.EncoderFallbackException leaking from the + // encoder. + [Test] + public async Task Symbol_LoneSurrogateValue_ThrowsIngressError() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + Assert.Throws(() => + { + sender.Table("t").Symbol("sym", "x\uD800").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + }); + } + + [Test] + public async Task EndToEnd_AutoFlushByRows_FiresOnThreshold() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush_rows=2;auto_flush_interval=off;auto_flush_bytes=off;"); + + // Send 5 rows; expect 3 frames (2 + 2 + 1 from final close). + for (var i = 0; i < 5; i++) + { + sender.Table("t") + .Column("v", (long)i) + .At(DateTime.UtcNow); + } + + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 3); + Assert.That(server.ReceivedFrames.Count, Is.GreaterThanOrEqualTo(3)); + } + + [Test] + public async Task EndToEnd_ServerErrorAck_TurnsSenderTerminal() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildErrorAck(QwpStatusCode.WriteError, sequence: 0, "table not writable"), + }); + await server.StartAsync(); + + var sender = NewSender(server, "auto_flush=off;on_server_error=halt;"); + try + { + var qwp = (IQwpWebSocketSender)sender; + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + Assert.CatchAsync(async () => await qwp.PingAsync()); + + Assert.Catch(() => sender.Table("t").Column("v", 2L).At(DateTime.UtcNow)); + Assert.Catch(() => sender.Send()); + } + finally + { + try { sender.Dispose(); } catch { } + } + } + + [Test] + public async Task EndToEnd_MultipleTables_SingleFrame() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("a").Column("x", 1L).At(DateTime.UtcNow); + sender.Table("b").Column("y", 2.0).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1), "two tables share one frame"); + var frame = server.ReceivedFrames.First(); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCount, 2)), + Is.EqualTo(2)); + } + + [Test] + public async Task EndToEnd_SecondFlush_StaysSelfSufficient() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 2); + var frames = server.ReceivedFrames.Take(2).ToList(); + + // Schemas always travel inline; both frames carry the same column count and column-defs. + // Byte 17 is col_count (= 2: user column "v" + designated TS). + Assert.That(frames[0][17], Is.EqualTo((byte)2)); + Assert.That(frames[1][17], Is.EqualTo((byte)2)); + } + + [Test] + public async Task EndToEnd_FlushTruncateThenAppend_DoesNotHang() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + sender.Truncate(); + + var completed = await Task.Run(() => + { + var thread = new System.Threading.Thread(() => + { + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + }) + { + IsBackground = true, // a hang must not keep a live foreground thread spinning after the run. + }; + thread.Start(); + return thread.Join(System.TimeSpan.FromSeconds(5)); + }); + + Assert.That(completed, Is.True, + "append after Flush()+Truncate() hung — infinite loop in GrowTo(0, required)"); + await WaitFor(() => server.ReceivedFrames.Count >= 2); + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(2)); + } + + [Test] + public async Task EndToEnd_NewColumnMidStream_GrowsInlineSchema() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + sender.Table("t").Column("v", 2L).Column("w", 3.14).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 2); + var frames = server.ReceivedFrames.Take(2).ToList(); + + // col_count grew from 2 → 3 (the new "w" column plus the designated TS). + Assert.That(frames[0][17], Is.EqualTo((byte)2)); + Assert.That(frames[1][17], Is.EqualTo((byte)3)); + } + + [Test] + public async Task EndToEnd_SymbolDictAccumulatesAcrossFlushes() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Symbol("k", "us").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + sender.Table("t").Symbol("k", "eu").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 2); + var frames = server.ReceivedFrames.Take(2).ToList(); + + // Frames stay self-sufficient (delta_start=0), but symbol ids accumulate: the + // second frame re-emits the full prefix ["us", "eu"] so a client symbol id + // always denotes the same value across flushes. + Assert.That(frames[0][12], Is.EqualTo(0)); + Assert.That(frames[0][13], Is.EqualTo(1)); + + Assert.That(frames[1][12], Is.EqualTo(0)); + Assert.That(frames[1][13], Is.EqualTo(2)); + Assert.That(System.Text.Encoding.UTF8.GetString(frames[1], 15, 2), Is.EqualTo("us")); + Assert.That(System.Text.Encoding.UTF8.GetString(frames[1], 18, 2), Is.EqualTo("eu")); + } + + [Test] + public async Task SenderNew_Routes_ws_Scheme_To_QwpWebSocketSender() + { + await using var server = StartServerWithOkAcks(); + var port = server.Uri.Port; + + using var sender = Sender.New($"ws::addr=127.0.0.1:{port};auto_flush=off;"); + Assert.That(sender, Is.InstanceOf()); + } + + [Test] + public async Task AuthHeader_BasicAuth_ReachesServerOnUpgrade() + { + await using var server = StartServerWithOkAcks(); + var port = server.Uri.Port; + + using var sender = Sender.New( + $"ws::addr=127.0.0.1:{port};username=alice;password=secret;auto_flush=off;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.LastUpgradeHeaders is not null); + var auth = server.LastUpgradeHeaders!.TryGetValue("Authorization", out var v) ? v : null; + Assert.That(auth, Does.StartWith("Basic ")); + var decoded = System.Text.Encoding.UTF8.GetString( + Convert.FromBase64String(auth!.Substring("Basic ".Length))); + Assert.That(decoded, Is.EqualTo("alice:secret")); + } + + [Test] + public async Task AuthHeader_BearerToken_ReachesServerOnUpgrade() + { + await using var server = StartServerWithOkAcks(); + var port = server.Uri.Port; + + using var sender = Sender.New( + $"ws::addr=127.0.0.1:{port};token=abc123;auto_flush=off;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.LastUpgradeHeaders is not null); + var auth = server.LastUpgradeHeaders!.TryGetValue("Authorization", out var v) ? v : null; + Assert.That(auth, Is.EqualTo("Bearer abc123")); + } + + [Test] + public async Task AuthHeader_NoCreds_NoAuthorizationHeader() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.LastUpgradeHeaders is not null); + Assert.That(server.LastUpgradeHeaders!.ContainsKey("Authorization"), Is.False); + } + + [Test] + public async Task ConnectFailure_ClosedPort_RaisesIngressError() + { + var ex = Assert.Catch(() => + Sender.New("ws::addr=127.0.0.1:1;auto_flush=off;")); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + await Task.CompletedTask; + } + + [Test] + public async Task Tls_SelfSignedCert_VerifyOff_ConnectsAndSends() + { + using var cert = NewSelfSignedCertificate("CN=localhost"); + long ackSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + TlsCertificate = cert, + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref ackSeq) - 1; + return BuildOkAck(seq); + }, + }); + await server.StartAsync(); + + var port = server.Uri.Port; + using var sender = Sender.New( + $"wss::addr=127.0.0.1:{port};tls_verify=unsafe_off;auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1)); + } + + [Test] + public async Task DisposeAsync_FlushesAndCleansUp() + { + await using var server = StartServerWithOkAcks(); + var sender = NewSender(server, "auto_flush=off;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await ((IAsyncDisposable)sender).DisposeAsync(); + + Assert.That(server.ReceivedFrames.Count, Is.GreaterThanOrEqualTo(1)); + + Assert.Throws(() => sender.Table("t").Column("v", 2L).At(DateTime.UtcNow)); + } + + [Test] + public async Task DisposeAsync_OnTerminalSender_RethrowsLatchedError() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildErrorAck(QwpStatusCode.WriteError, sequence: 0, "boom"), + }); + await server.StartAsync(); + + var sender = NewSender(server, "auto_flush=off;on_server_error=halt;"); + var qwp = (IQwpWebSocketSender)sender; + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + Assert.CatchAsync(async () => await qwp.PingAsync()); + + Assert.ThrowsAsync( + async () => await ((IAsyncDisposable)sender).DisposeAsync()); + } + + [Test] + public async Task SendAsync_CompletesFastWhileServerStalls() + { + using var ackGate = new SemaphoreSlim(0, int.MaxValue); + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + ackGate.Wait(); + return BuildOkAck(0); + }, + }); + await server.StartAsync(); + + using var sender = NewSender(server, "auto_flush=off;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + + // Cursor engine path: SendAsync returns once the frame is in the ring, regardless of ACK. + await sender.SendAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + ackGate.Release(8); + } + + [Test] + public async Task PingAsync_DoesNotBlockCallerWhileServerStalls() + { + using var ackGate = new SemaphoreSlim(0, int.MaxValue); + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + ackGate.Wait(); + return BuildOkAck(0); + }, + }); + await server.StartAsync(); + + using var sender = NewSender(server, "auto_flush=off;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + var firstSend = sender.SendAsync(); + // Frame is enqueued and on the wire; server's FrameHandler is parked on ackGate.Wait(). + var pending = ((QuestDB.Senders.IQwpWebSocketSender)sender).PingAsync().AsTask(); + Assert.That(pending.IsCompleted, Is.False, "PingAsync must not complete while a frame is unacked"); + + ackGate.Release(); + await firstSend.WaitAsync(TimeSpan.FromSeconds(5)); + await pending.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(pending.IsCompletedSuccessfully, Is.True); + + ackGate.Release(8); + } + + [Test] + public async Task AtAsync_AutoFlush_TrulyAsync() + { + using var ackGate = new SemaphoreSlim(0, int.MaxValue); + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + ackGate.Wait(); + return BuildOkAck(0); + }, + }); + await server.StartAsync(); + + using var sender = NewSender(server, + "auto_flush=on;auto_flush_rows=1;auto_flush_interval=off;auto_flush_bytes=off;"); + + // First AtAsync triggers an auto-flush; with the server stalled the returned ValueTask + // should land on the in-flight wait, not synchronously. + sender.Table("t").Column("v", 1L); + var pending = sender.AtAsync(DateTime.UtcNow); + // ValueTask may complete sync if auto-flush didn't fire; with auto_flush_rows=1 it must enqueue. + // The send is enqueued without awaitDrain so the ValueTask should complete quickly even with + // a stalled server — assert at least no crash and successful completion. + await pending.AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + + ackGate.Release(16); + } + + [Test] + public async Task Tls_SelfSignedCert_VerifyOn_ConnectFails() + { + using var cert = NewSelfSignedCertificate("CN=localhost"); + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + TlsCertificate = cert, + FrameHandler = _ => BuildOkAck(0), + }); + await server.StartAsync(); + var port = server.Uri.Port; + + Assert.Catch(() => + Sender.New($"wss::addr=127.0.0.1:{port};tls_verify=on;auto_flush=off;")); + } + + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.ProtocolError)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.InvalidMessageType)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.InvalidPayloadData)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.MessageTooBig)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.MandatoryExtension)] + public async Task ServerClosesWithProtocolViolation_TerminatesWithoutReconnect( + System.Net.WebSockets.WebSocketCloseStatus status) + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildOkAck(0), + CloseAfterFrameCount = 1, + CloseStatus = status, + CloseReason = "boom", + }); + await server.StartAsync(); + + var sender = NewSender(server, + "auto_flush=off;reconnect_initial_backoff_millis=10;reconnect_max_backoff_millis=50;reconnect_max_duration_millis=5000;"); + try + { + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + // Once the protocol-violation close hits the engine, the next API call must surface a + // terminal IngressError carrying ProtocolViolation — without sitting in reconnect. + IngressError? caught = null; + await WaitFor(() => + { + try + { + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + return false; + } + catch (IngressError ex) + { + caught = ex; + return true; + } + }, timeoutMs: 5000); + + Assert.That(caught, Is.Not.Null); + var rootCode = caught!.code is ErrorCode.ProtocolViolation + ? caught.code + : (caught.InnerException as IngressError)?.code ?? caught.code; + Assert.That(rootCode, Is.EqualTo(ErrorCode.ProtocolViolation)); + + // No reconnect attempts: the engine must terminate after the single upgrade. Frame count + // races with the in-flight close so isn't a stable signal — UpgradeCount is. + await Task.Delay(200); + Assert.That(server.UpgradeCount, Is.EqualTo(1)); + } + finally + { + try { sender.Dispose(); } catch { } + } + } + + [Test] + public async Task ServerClosesAfterFirstFrame_ReconnectsThenTerminalAfterBudget() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildOkAck(0), + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + CloseReason = "boom", + }); + await server.StartAsync(); + + using var sender = NewSender(server, + "auto_flush=off;reconnect_initial_backoff_millis=10;reconnect_max_backoff_millis=50;reconnect_max_duration_millis=500;"); + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + // Server is gone after frame 1; sender retries through the reconnect budget then terminalises. + await WaitFor(() => + { + try + { + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + return false; + } + catch (IngressError) + { + return true; + } + }, timeoutMs: 5000); + } + + private static System.Security.Cryptography.X509Certificates.X509Certificate2 NewSelfSignedCertificate(string subject) + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + subject, rsa, System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var sanBuilder = new System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + sanBuilder.AddIpAddress(System.Net.IPAddress.Parse("127.0.0.1")); + req.CertificateExtensions.Add(sanBuilder.Build()); + + var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); + // Kestrel needs a cert with an exportable private key. Keep the byte-array constructor for + // net6.0–net8.0 compatibility; X509CertificateLoader is net9+ only. +#pragma warning disable SYSLIB0057 + var pfx = ephemeral.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Pfx); + return new System.Security.Cryptography.X509Certificates.X509Certificate2( + pfx, (string?)null, + System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 + } + + private static readonly DateTime TxnTs = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static bool IsDeferred(byte[] frame) + => (frame[QwpConstants.OffsetFlags] & QwpConstants.FlagDeferCommit) != 0; + + [Test] + public async Task Transaction_ExplicitPerTableApi_StillThrows() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "transaction=on;auto_flush=off;"); + + // WS transactions are a connection mode, not a per-table begin; these stay unsupported. + Assert.Throws(() => sender.Transaction("t")); + Assert.Throws(() => sender.Rollback()); + } + + [Test] + public async Task Transaction_AutoFlushDefers_ExplicitSendCommits() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, + "transaction=on;auto_flush_rows=2;auto_flush_interval=off;auto_flush_bytes=off;"); + + sender.Table("t").Column("v", 1L).At(TxnTs); + sender.Table("t").Column("v", 2L).At(TxnTs); // hits auto_flush_rows=2 → deferred frame + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(IsDeferred(server.ReceivedFrames.First()), Is.True, "auto-flush frame must defer commit"); + + sender.Table("t").Column("v", 3L).At(TxnTs); + sender.Send(); // residual row 3 → committing frame + await WaitFor(() => server.ReceivedFrames.Count >= 2); + Assert.That(IsDeferred(server.ReceivedFrames.Last()), Is.False, "explicit Send must commit (no defer flag)"); + } + + [Test] + public async Task Transaction_CommitWithNoResidual_SendsEmptyCommitFrame() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, + "transaction=on;auto_flush_rows=2;auto_flush_interval=off;auto_flush_bytes=off;"); + + sender.Table("t").Column("v", 1L).At(TxnTs); + sender.Table("t").Column("v", 2L).At(TxnTs); // auto-flush → deferred frame + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + sender.Commit(); // nothing buffered, but a commit is owed → empty commit frame + await WaitFor(() => server.ReceivedFrames.Count >= 2); + + var commit = server.ReceivedFrames.Last(); + Assert.That(IsDeferred(commit), Is.False); + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(commit.AsSpan(QwpConstants.OffsetTableCount, 2)), + Is.EqualTo(0), "commit frame carries zero tables"); + } + + [Test] + public async Task Transaction_Commit_RequiresTransactionalMode() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); // transaction not enabled + + var ex = Assert.Throws(() => sender.Commit()); + Assert.That(ex!.Message, Does.Contain("transaction")); + } + + [Test] + public async Task Transaction_CommitAsync_FlushesBufferedRowsNonDeferred() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "transaction=on;auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(TxnTs); + sender.Table("t").Column("v", 2L).At(TxnTs); + await sender.CommitAsync(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + Assert.That(IsDeferred(server.ReceivedFrames.Single()), Is.False); + } + + [Test] + public async Task Transaction_Dispose_CommitsDeferredRows() + { + await using var server = StartServerWithOkAcks(); + await using (var sender = NewSender(server, + "transaction=on;auto_flush_rows=1;auto_flush_interval=off;auto_flush_bytes=off;close_flush_timeout_millis=2000;")) + { + sender.Table("t").Column("v", 1L).At(TxnTs); // auto-flush row=1 → deferred frame + await WaitFor(() => server.ReceivedFrames.Count >= 1); + } + + // dispose must emit a committing frame for the deferred rows + await WaitFor(() => server.ReceivedFrames.Count >= 2); + Assert.That(IsDeferred(server.ReceivedFrames.Last()), Is.False); + } + + [Test] + public async Task NonTransactional_FramesNeverDefer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(TxnTs); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + Assert.That(IsDeferred(server.ReceivedFrames.Single()), Is.False); + } + + [Test] + public async Task AsyncMode_PipelinedBatches_AllAcked() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + for (var i = 0; i < 20; i++) + { + sender.Table("t").Column("v", (long)i).At(DateTime.UtcNow); + } + + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1), "all 20 rows packed in a single frame, sent on Send()"); + } + + [Test] + public async Task AsyncMode_AutoFlushDoesNotBlockOnAck() + { + // Server stalls on ACKs (slow handler). Async mode should keep accepting rows without blocking + // the producer until the segment ring fills up. + var ackGate = new SemaphoreSlim(0, int.MaxValue); + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + ackGate.Wait(TimeSpan.FromSeconds(2)); + var seq = Interlocked.Increment(ref nextSeq) - 1; + return BuildOkAck(seq); + }, + }); + await server.StartAsync(); + using var sender = NewSender(server, "auto_flush_rows=1;auto_flush_interval=off;auto_flush_bytes=off;"); + + // Push 4 rows; each triggers auto-flush. Window is 4, so 4 fit before producer would block. + for (var i = 0; i < 4; i++) + { + sender.Table("t").Column("v", (long)i).At(DateTime.UtcNow); + } + + // Now release ACKs one by one and make sure things drain. + for (var i = 0; i < 4; i++) + { + ackGate.Release(); + } + + await ((IQwpWebSocketSender)sender).PingAsync(); + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(4)); + } + + [Test] + public async Task DurableAck_ServerSendsPerTableSeqTxns_TrackedSeparately() + { + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + DurableAckEnabled = true, + FrameHandlerMulti = _ => + { + // For each batch, server emits a DURABLE_ACK first (out-of-band watermark) and + // then the OK that completes the request. + var seq = Interlocked.Increment(ref nextSeq) - 1; + return new[] + { + BuildDurableAckBytes(("trades", 100 + seq)), + BuildOkAckWithEntries(seq, ("trades", 200 + seq)), + }; + }, + }); + await server.StartAsync(); + // close_flush_timeout_millis=0: durable-ack deliberately lags OK-ack here, so a close drain would time out. + using var sender = NewSender(server, "auto_flush=off;request_durable_ack=on;close_flush_timeout_millis=0;"); + + var ws = (IQwpWebSocketSender)sender; + sender.Table("trades").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + sender.Table("trades").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => ws.GetHighestAckedSeqTxn("trades") == 201L + && ws.GetHighestDurableSeqTxn("trades") == 101L); + + Assert.That(ws.GetHighestAckedSeqTxn("trades"), Is.EqualTo(201L), "OK frame's per-table entry"); + Assert.That(ws.GetHighestDurableSeqTxn("trades"), Is.EqualTo(101L), "DURABLE_ACK frame's per-table entry"); + } + + [Test] + public async Task DurableAck_UpgradeRequestIncludesOptInHeader() + { + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + DurableAckEnabled = true, + FrameHandler = _ => BuildOkAck(Interlocked.Increment(ref nextSeq) - 1), + }); + await server.StartAsync(); + using var sender = NewSender(server, "auto_flush=off;request_durable_ack=on;"); + + // Force a no-op flush so we know the upgrade has completed and the server captured the headers. + sender.Send(); + + Assert.That(server.LastUpgradeHeaders!.ContainsKey("X-QWP-Request-Durable-Ack")); + Assert.That(server.LastUpgradeHeaders["X-QWP-Request-Durable-Ack"], Is.EqualTo("true")); + } + + [Test] + public async Task GetHighest_OnUnknownTable_ReturnsMinusOne() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + var ws = (IQwpWebSocketSender)sender; + Assert.That(ws.GetHighestAckedSeqTxn("nonexistent"), Is.EqualTo(-1L)); + Assert.That(ws.GetHighestDurableSeqTxn("nonexistent"), Is.EqualTo(-1L)); + } + + [Test] + public async Task PingAsync_AfterPipelinedBatches_DrainsRing() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + for (var i = 0; i < 2; i++) + { + sender.Table("t").Column("v", (long)i).At(DateTime.UtcNow); + } + + await sender.SendAsync(); + await ((IQwpWebSocketSender)sender).PingAsync(); + + Assert.That(server.ReceivedFrames.Count, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public async Task Ping_AfterPipelinedBatches_DrainsRing() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + for (var i = 0; i < 4; i++) + { + sender.Table("t").Column("v", (long)i).At(DateTime.UtcNow); + } + + sender.Send(); + var ws = (IQwpWebSocketSender)sender; + ws.Ping(); + + // After Ping, every sent batch is acknowledged. + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1)); + } + + [Test] + public async Task AsyncMode_ServerErrorOnBatch_TurnsTerminal() + { + var seenFrames = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var n = Interlocked.Increment(ref seenFrames); + return n switch + { + 1 => BuildOkAck(0), + _ => BuildErrorAck(QwpStatusCode.WriteError, sequence: 1, "boom"), + }; + }, + }); + await server.StartAsync(); + var sender = NewSender(server, "auto_flush=off;on_server_error=halt;"); + try + { + var qwp = (IQwpWebSocketSender)sender; + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); // first batch — OK + + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + Assert.CatchAsync(async () => await qwp.PingAsync()); + } + finally + { + try { sender.Dispose(); } catch { } + } + } + + [Test] + public async Task EndToEnd_Sf_AutoFlushByRows_RoutesThroughEngine_NotTransport() + { + // Regression: SF mode used to NPE in auto-flush because FlushIfNecessary fell through to + // FlushAndAwaitAck (which dereferences the null _transport in SF mode). + await using var server = StartServerWithOkAcks(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-sf-autoflush-" + Guid.NewGuid().ToString("N")); + try + { + using var sender = NewSender(server, + $"auto_flush_rows=2;auto_flush_interval=off;auto_flush_bytes=off;" + + $"sf_dir={sfRoot};sender_id=af-test;sf_max_bytes=4096;"); + + for (var i = 0; i < 5; i++) + { + sender.Table("t").Column("v", (long)i).At(DateTime.UtcNow); + } + + sender.Send(); + ((IQwpWebSocketSender)sender).Ping(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(server.ReceivedFrames.Count, Is.GreaterThanOrEqualTo(1)); + } + finally + { + TryDeleteDirectory(sfRoot); + } + } + + [Test] + public async Task EndToEnd_Sf_EveryFrame_IsSelfSufficient_AcrossMultipleFlushes() + { + await using var server = StartServerWithOkAcks(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-sf-multi-" + Guid.NewGuid().ToString("N")); + try + { + using (var sender = NewSender(server, + $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-multi;sf_max_bytes=4096;")) + { + for (var i = 0; i < 3; i++) + { + sender.Table("trades") + .Symbol("ticker", "ETH-USD") + .Column("price", 1000.0 + i) + .At(new DateTime(2026, 4, 28, 12, 0, i, DateTimeKind.Utc)); + sender.Send(); + } + + await WaitFor(() => server.ReceivedFrames.Count >= 3); + sender.Ping(); + } + + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(3)); + foreach (var frame in server.ReceivedFrames) + { + Assert.That(frame[12], Is.EqualTo(0x00), "delta_start = 0 in self-sufficient mode"); + Assert.That(frame[13], Is.EqualTo(0x01), "delta_count = 1 (single symbol re-emitted each flush)"); + } + } + finally + { + TryDeleteDirectory(sfRoot); + } + } + + [Test] + public async Task EndToEnd_Sf_SingleRow_FrameReachesServerAndIsSelfSufficient() + { + await using var server = StartServerWithOkAcks(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-sf-smoke-" + Guid.NewGuid().ToString("N")); + try + { + using (var sender = NewSender(server, + $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-a;sf_max_bytes=4096;")) + { + sender.Table("trades") + .Symbol("ticker", "ETH-USD") + .Column("price", 2615.54) + .At(new DateTime(2026, 4, 28, 12, 0, 0, DateTimeKind.Utc)); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + sender.Ping(); + } + + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1)); + var frame = server.ReceivedFrames.First(); + + // SF frames are self-sufficient: delta dict starts at id 0 with the full known set, + // even after the engine commits. (The inline schema travels with every frame anyway.) + Assert.That(frame[12], Is.EqualTo(0x00), "delta_start = 0 in self-sufficient mode"); + Assert.That(frame[13], Is.EqualTo(0x01), "delta_count = 1 (single symbol 'ETH-USD')"); + } + finally + { + TryDeleteDirectory(sfRoot); + } + } + + [Test] + public async Task EndToEnd_Sf_DisposeReleasesSlotLockSoSecondSenderCanReclaim() + { + await using var server = StartServerWithOkAcks(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-sf-relock-" + Guid.NewGuid().ToString("N")); + try + { + using (var first = NewSender(server, $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-a;")) + { + first.Table("t").Column("v", 1L).At(DateTime.UtcNow); + first.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + } + + // After disposing the first sender the slot lock should be released — opening a second + // sender on the same slot must succeed, not throw "already locked". + using var second = NewSender(server, $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-a;"); + second.Table("t").Column("v", 2L).At(DateTime.UtcNow); + second.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 2); + } + finally + { + TryDeleteDirectory(sfRoot); + } + } + + [Test] + public async Task EndToEnd_Sf_TwoSendersSameSlot_SecondFailsLockCollision() + { + await using var server = StartServerWithOkAcks(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-sf-collide-" + Guid.NewGuid().ToString("N")); + try + { + using var first = NewSender(server, $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-a;"); + + var ex = Assert.Catch(() => + NewSender(server, $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-a;")); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ConfigError)); + Assert.That(ex.Message, Does.Contain("already locked")); + } + finally + { + TryDeleteDirectory(sfRoot); + } + } + + [Test] + public async Task Dispose_Sf_ZeroCloseFlushTimeout_StillPersistsBufferedRowsToDisk() + { + // close_flush_timeout_millis=0 means "don't wait for acks", not "discard the buffer". In SF + // mode the close-time encode-to-ring is a local disk append (durability), not a network wait, + // so buffered-but-unsent rows must still be persisted to the segment ring on dispose. + // FrameHandler => null: the server never acks, so the persisted frame is never trimmed off disk. + await using var server = new DummyQwpServer(new DummyQwpServerOptions { FrameHandler = _ => null }); + await server.StartAsync(); + var sfRoot = Path.Combine(Path.GetTempPath(), "qwp-sf-closeflush0-" + Guid.NewGuid().ToString("N")); + try + { + using (var sender = NewSender(server, + $"auto_flush=off;sf_dir={sfRoot};sender_id=svc-a;sf_max_bytes=4096;close_flush_timeout_millis=0;")) + { + // Buffered only — auto_flush is off and Send() is never called, so the close-time encode + // is the row's only path to the on-disk ring. + sender.Table("trades") + .Symbol("ticker", "ETH-USD") + .Column("price", 2615.54) + .At(new DateTime(2026, 4, 28, 12, 0, 0, DateTimeKind.Utc)); + } + + Assert.That(SegmentBytesOnDisk(sfRoot), Is.GreaterThan(0), + "SF segment ring must retain the buffered row after a zero close-flush-timeout dispose"); + } + finally + { + TryDeleteDirectory(sfRoot); + } + } + + [Test] + public async Task ConnectionListener_FiresConnectedOnFirstConnect() + { + await using var server = StartServerWithOkAcks(); + var listener = new CapturingListener(); + using var sender = NewSenderWithListener(server, "auto_flush=off;", listener); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + AssertEventuallyTrue( + () => listener.Events.Any(e => e.Kind == SenderConnectionEventKind.Connected), + "Connected event never delivered"); + var connected = listener.Events.First(e => e.Kind == SenderConnectionEventKind.Connected); + Assert.That(connected.Cause, Is.Null); + Assert.That(ws.DroppedConnectionNotifications, Is.EqualTo(0L)); + } + + [Test] + public async Task ConnectionListener_FiresAuthFailedOn401() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = System.Net.HttpStatusCode.Unauthorized, + }); + await server.StartAsync(); + + var listener = new CapturingListener(); + var port = server.Uri.Port; + var options = new SenderOptions($"ws::addr=127.0.0.1:{port};auto_flush=off;"); + options.ConnectionListener = listener; + var ex = Assert.Catch(() => new QwpWebSocketSender(options)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.AuthError)); + + AssertEventuallyTrue( + () => listener.Events.Any(e => e.Kind == SenderConnectionEventKind.AuthFailed), + "AuthFailed event never delivered"); + var authFailed = listener.Events.First(e => e.Kind == SenderConnectionEventKind.AuthFailed); + Assert.That(authFailed.Cause, Is.InstanceOf()); + } + + [Test] + public async Task ConnectionListener_ExceptionInOnEvent_DoesNotCrashDispatcher() + { + await using var server = StartServerWithOkAcks(); + var blewUp = 0; + var caught = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var listener = new DelegateListener(evt => + { + Interlocked.Increment(ref blewUp); + caught.TrySetResult(true); + throw new InvalidOperationException("boom"); + }); + + using var sender = NewSenderWithListener(server, "auto_flush=off;", listener); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + await caught.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(blewUp, Is.GreaterThan(0)); + + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + Assert.DoesNotThrowAsync(async () => await sender.SendAsync()); + } + + private sealed class DelegateListener : ISenderConnectionListener + { + private readonly Action _onEvent; + public DelegateListener(Action onEvent) => _onEvent = onEvent; + public void OnEvent(SenderConnectionEvent evt) => _onEvent(evt); + } + + private static void AssertEventuallyTrue(Func predicate, string message, int timeoutMs = 5000) + { + var deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (predicate()) return; + Thread.Sleep(20); + } + Assert.Fail(message); + } + + [Test] + public async Task FlushAndGetSequenceAsync_NothingPublished_ReturnsMinusOne() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + var fsn = await ws.FlushAndGetSequenceAsync(); + Assert.That(fsn, Is.EqualTo(-1L)); + Assert.That(ws.AckedFsn, Is.EqualTo(-1L)); + } + + [Test] + public async Task FlushAndGetSequenceAsync_PublishesSequentialFsns() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + var fsn1 = await ws.FlushAndGetSequenceAsync(); + Assert.That(fsn1, Is.EqualTo(0L)); + + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + var fsn2 = await ws.FlushAndGetSequenceAsync(); + Assert.That(fsn2, Is.EqualTo(1L)); + } + + [Test] + public async Task AwaitAckedFsnAsync_NegativeTarget_ReturnsTrueImmediately() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + Assert.That(await ws.AwaitAckedFsnAsync(-1L, TimeSpan.Zero), Is.True); + } + + [Test] + public async Task AwaitAckedFsnAsync_AfterPublish_ReachesTarget() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + var published = await ws.FlushAndGetSequenceAsync(); + + Assert.That(await ws.AwaitAckedFsnAsync(published, TimeSpan.FromSeconds(5)), Is.True); + Assert.That(ws.AckedFsn, Is.GreaterThanOrEqualTo(published)); + } + + [Test] + public async Task ColumnDecimal64_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnDecimal64("p", 12.34m, 2); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + Assert.That(payload.IndexOf((byte)QwpTypeCode.Decimal64), Is.GreaterThan(0)); + } + + [Test] + public async Task ColumnDecimal256_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnDecimal256("p", -1m, 0); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + Assert.That(payload.IndexOf((byte)QwpTypeCode.Decimal256), Is.GreaterThan(0)); + } + + [Test] + public async Task ColumnDecimal64_LimbOverload_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnDecimal64("p", unscaledValue: 1234567890123L, scale: 4); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + var typeCodeOffset = payload.IndexOf((byte)QwpTypeCode.Decimal64); + Assert.That(typeCodeOffset, Is.GreaterThan(0)); + // After type code: TS col def (nameLen=0, type 0x10), then user col data: null flag + scale + 8-byte LE. + var scaleOffset = typeCodeOffset + 1 + 2 + 1; + Assert.That(payload[scaleOffset], Is.EqualTo((byte)4), "scale prefix"); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(payload.Slice(scaleOffset + 1, 8)), + Is.EqualTo(1234567890123L)); + } + + [Test] + public async Task ColumnDecimal128_LimbOverload_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnDecimal128("p", lo: 0x0102030405060708L, hi: 0x1112131415161718L, scale: 6); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + var typeCodeOffset = payload.IndexOf((byte)QwpTypeCode.Decimal128); + Assert.That(typeCodeOffset, Is.GreaterThan(0)); + var scaleOffset = typeCodeOffset + 1 + 2 + 1; + Assert.That(payload[scaleOffset], Is.EqualTo((byte)6), "scale prefix"); + var unscaled = payload.Slice(scaleOffset + 1, 16); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(0, 8)), + Is.EqualTo(0x0102030405060708L)); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(8, 8)), + Is.EqualTo(0x1112131415161718L)); + } + + [Test] + public async Task ColumnDecimal256_LimbOverload_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnDecimal256("p", + l0: 0x0102030405060708L, + l1: 0x1112131415161718L, + l2: 0x2122232425262728L, + l3: 0x3132333435363738L, + scale: 12); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + var typeCodeOffset = payload.IndexOf((byte)QwpTypeCode.Decimal256); + Assert.That(typeCodeOffset, Is.GreaterThan(0)); + var scaleOffset = typeCodeOffset + 1 + 2 + 1; + Assert.That(payload[scaleOffset], Is.EqualTo((byte)12), "scale prefix"); + var unscaled = payload.Slice(scaleOffset + 1, 32); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(0, 8)), Is.EqualTo(0x0102030405060708L)); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(8, 8)), Is.EqualTo(0x1112131415161718L)); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(16, 8)), Is.EqualTo(0x2122232425262728L)); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(unscaled.Slice(24, 8)), Is.EqualTo(0x3132333435363738L)); + } + + [Test] + public async Task ColumnBinary_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnBinary("blob", new byte[] { 0x10, 0x20, 0x30 }); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + Assert.That(payload.IndexOf((byte)QwpTypeCode.Binary), Is.GreaterThan(0)); + } + + [Test] + public async Task ColumnIPv4_RoundTripsToServer() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + ws.ColumnIPv4("ip", System.Net.IPAddress.Parse("192.168.1.1")); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First(); + var typeCodeOffset = payload.AsSpan().IndexOf((byte)QwpTypeCode.IPv4); + Assert.That(typeCodeOffset, Is.GreaterThan(0)); + // After the IPv4 type code: TS col def (nameLen=0, type 0x10), then data section: + // user col null_flag (0x00), then 4 bytes IPv4 value LE. + var valueOffset = typeCodeOffset + 1 + 2 + 1; + Assert.That(payload[valueOffset - 1], Is.EqualTo((byte)0x00), "user col null flag"); + Assert.That(System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(payload.AsSpan(valueOffset, 4)), + Is.EqualTo(0xC0A80101u), + "192.168.1.1 must serialise as int 0xC0A80101 (octet 'a' is the MSB)"); + } + + [Test] + public async Task ColumnIPv4_RejectsIPv6Address() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + sender.Table("t"); + Assert.Throws(() => ws.ColumnIPv4("ip", System.Net.IPAddress.Parse("::1"))); + } + + [Test] + public async Task OversizeRow_TripsPerRowGuard() + { + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + MaxBatchSize = 4096, + FrameHandler = _ => BuildOkAck(Interlocked.Increment(ref nextSeq) - 1), + }); + await server.StartAsync(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + var big = new string('x', 8192); + var ex = Assert.Throws( + () => sender.Table("t").Column("s", big).At(DateTime.UtcNow)); + Assert.That(ex!.Message, Does.Contain("row too large for server batch cap")); + + // The oversize row is rolled back; the sender stays usable for the next row. + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 2); + } + + [Test] + public async Task OversizeRow_BeforeCapNegotiated_TripsGuardAgainstProtocolLimit() + { + // The server advertises no X-QWP-Max-Batch-Size, so NegotiatedMaxBatchSize stays 0 — the + // same state a sender is in before its first connect (e.g. initial_connect_mode=async). + // Without the protocol-limit fallback the per-row guard would be bypassed and the oversize + // row would be buffered and only rejected later (terminally) by the server. + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildOkAck(Interlocked.Increment(ref nextSeq) - 1), + }); + await server.StartAsync(); + using var sender = NewSender(server, "auto_flush=off;"); + + // Accumulate > 90% of the 16 MiB protocol limit from a reused chunk (no single huge alloc). + var chunk = new string('x', 512 * 1024); + var ex = Assert.Throws(() => + { + sender.Table("t"); + for (var i = 0; i < 32; i++) + { + sender.Column($"c{i}", chunk); + } + + sender.At(DateTime.UtcNow); + }); + Assert.That(ex!.Message, Does.Contain("row too large for protocol batch limit")); + + // The oversize row is rolled back; the sender stays usable for a normal row. + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + } + + [Test] + public async Task OversizeBatch_TripsFlushGuard() + { + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + MaxBatchSize = 8192, + FrameHandler = _ => BuildOkAck(Interlocked.Increment(ref nextSeq) - 1), + }); + await server.StartAsync(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + var chunk = new string('y', 2048); + for (var i = 0; i < 12; i++) + { + sender.Table("t").Column("s", chunk).At(DateTime.UtcNow); + } + + var ex = Assert.Throws(() => sender.Send()); + Assert.That(ex!.Message, Does.Contain("batch too large for server batch cap")); + } + + [Test] + public async Task AutoFlushBytes_ClampedToServerMaxBatchSize() + { + const int serverCap = 16 * 1024; + var clampBudget = (long)serverCap * 9 / 10; + + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + MaxBatchSize = serverCap, + FrameHandler = _ => BuildOkAck(Interlocked.Increment(ref nextSeq) - 1), + }); + await server.StartAsync(); + + using var sender = NewSender(server, + "auto_flush_bytes=262144;auto_flush_rows=off;auto_flush_interval=off;"); + + var chunk = new string('z', 512); + for (var i = 0; i < 200; i++) + { + sender.Table("t").Column("s", chunk).At(DateTime.UtcNow); + } + + sender.Send(); + ((IQwpWebSocketSender)sender).Ping(); + + await WaitFor(() => server.ReceivedFrames.Count >= 2); + + Assert.That(server.ReceivedFrames.Count, Is.GreaterThan(1), + "auto-flush must fire at the clamped budget, not the configured 256 KiB budget"); + var configuredBudget = 262144; + foreach (var frame in server.ReceivedFrames) + { + Assert.That(frame.Length, Is.LessThanOrEqualTo(serverCap), + "every frame must stay within the server-advertised batch cap"); + Assert.That(frame.Length, Is.LessThan(configuredBudget), + "no frame may reach the unclamped configured budget"); + } + + var largest = server.ReceivedFrames.Max(f => f.Length); + Assert.That(largest, Is.GreaterThan(clampBudget / 2), + "a flush should occur close to the clamped budget, confirming the clamp drives it"); + } + + [Test] + public async Task NoAdvertisedCap_LargeRowPassesThrough() + { + long nextSeq = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildOkAck(Interlocked.Increment(ref nextSeq) - 1), + }); + await server.StartAsync(); + using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + + var big = new string('x', 1024 * 1024); + Assert.DoesNotThrow(() => sender.Table("t").Column("s", big).At(DateTime.UtcNow)); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 2); + } + + [Test] + public async Task EndToEnd_Decimal_InferringColumnOverload_IsRejectedOnQwp() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + // QWP needs an explicit decimal type + scale; the scale-inferring Column(name, decimal) is + // not supported and must redirect the caller to the explicit overloads. + sender.Table("t"); + var ex = Assert.Throws(() => sender.Column("d", 1.5m)); + Assert.That(ex!.Message, Does.Contain("ColumnDecimal64")); + } + + [Test] + public async Task EndToEnd_Decimal_ExcessFractionalDigits_RoundedNotRejected() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + // More fractional digits than the declared scale: rounded half-away-from-zero (1.235 -> 1.24), + // never rejected. A later value at the same scale is fine; the column scale was declared, not + // inferred, so there is no "first row locks the scale" surprise. + sender.Table("t"); + ws.ColumnDecimal128("d", 1.235m, 2); + sender.At(DateTime.UtcNow); + sender.Table("t"); + ws.ColumnDecimal128("d", 999.999m, 2); // rounds to 1000.00 + sender.At(DateTime.UtcNow); + sender.Send(); + + await WaitFor(() => server.ReceivedFrames.Count >= 1); + Assert.That(server.ReceivedFrames.Count, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public async Task EndToEnd_Decimal_MagnitudeOverflow_Throws() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + var ws = (IQwpWebSocketSender)sender; + + // The only rejection that survives the tolerant model: an integer part too large for the + // type's width. 10^20 does not fit Decimal64's signed 64-bit mantissa. + sender.Table("t"); + var ex = Assert.Throws(() => ws.ColumnDecimal64("d", 100000000000000000000m, 0)); + Assert.That(ex!.Message, Does.Contain("overflow")); + } + + private static DummyQwpServer StartServerWithOkAcks() + { + long nextSeq = 0; + var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => + { + var seq = Interlocked.Increment(ref nextSeq) - 1; + return BuildOkAck(seq); + }, + }); + server.StartAsync().GetAwaiter().GetResult(); + return server; + } + + private static QwpWebSocketSender NewSender(DummyQwpServer server, string extraOptions) + { + var port = server.Uri.Port; + var options = new SenderOptions($"ws::addr=127.0.0.1:{port};{extraOptions}"); + return new QwpWebSocketSender(options); + } + + // Sends one explicit batch and returns the raw QWP frame the server received. Two frames built + // from identical rows are byte-equal iff their encoded timestamps match, so callers compare a + // Local-kind row against a reference row to pin convert_local_to_utc behaviour. + private static async Task CaptureFirstFrame(string extraOptions, Action write) + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;" + extraOptions); + write(sender); + sender.Send(); + await WaitFor(() => server.ReceivedFrames.Count >= 1); + return server.ReceivedFrames.First(); + } + + private static QwpWebSocketSender NewSenderWithListener(DummyQwpServer server, string extraOptions, ISenderConnectionListener listener) + { + var port = server.Uri.Port; + var options = new SenderOptions($"ws::addr=127.0.0.1:{port};{extraOptions}"); + options.ConnectionListener = listener; + return new QwpWebSocketSender(options); + } + + private sealed class CapturingListener : ISenderConnectionListener + { + private readonly List _events = new(); + public IReadOnlyList Events + { + get { lock (_events) return _events.ToArray(); } + } + public void OnEvent(SenderConnectionEvent evt) + { + lock (_events) _events.Add(evt); + } + } + + private static byte[] BuildOkAck(long sequence) + { + var bytes = new byte[QwpConstants.OffsetTableCountInOkAck + 2]; + bytes[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(9, 2), 0); + return bytes; + } + + private static byte[] BuildOkAckWithEntries(long sequence, params (string Name, long SeqTxn)[] entries) + { + var size = QwpConstants.OffsetTableCountInOkAck + 2; + foreach (var e in entries) size += 2 + System.Text.Encoding.UTF8.GetByteCount(e.Name) + 8; + + var frame = new byte[size]; + frame[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(QwpConstants.OffsetTableCountInOkAck, 2), (ushort)entries.Length); + + var pos = QwpConstants.OffsetTableCountInOkAck + 2; + foreach (var e in entries) + { + var nameBytes = System.Text.Encoding.UTF8.GetBytes(e.Name); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(frame, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(pos, 8), e.SeqTxn); + pos += 8; + } + + return frame; + } + + private static byte[] BuildDurableAckBytes(params (string Name, long SeqTxn)[] entries) + { + var size = 3; + foreach (var e in entries) size += 2 + System.Text.Encoding.UTF8.GetByteCount(e.Name) + 8; + + var frame = new byte[size]; + frame[0] = (byte)QwpStatusCode.DurableAck; + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(1, 2), (ushort)entries.Length); + + var pos = 3; + foreach (var e in entries) + { + var nameBytes = System.Text.Encoding.UTF8.GetBytes(e.Name); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(frame, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(pos, 8), e.SeqTxn); + pos += 8; + } + + return frame; + } + + private static byte[] BuildErrorAck(QwpStatusCode status, long sequence, string message) + { + var msgBytes = System.Text.Encoding.UTF8.GetBytes(message); + var frame = new byte[QwpConstants.ErrorAckHeaderSize + msgBytes.Length]; + frame[0] = (byte)status; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(9, 2), (ushort)msgBytes.Length); + msgBytes.CopyTo(frame, QwpConstants.ErrorAckHeaderSize); + return frame; + } + + [Test] + public async Task At_DateTimeUnspecifiedKind_TreatedAsUtc() + { + await using var server = StartServerWithOkAcks(); + await using var sender = NewSender(server, "auto_flush=off;"); + + sender.Table("t").Column("v", 1L); + var unspecified = new DateTime(2026, 4, 28, 12, 0, 0, DateTimeKind.Unspecified); + Assert.DoesNotThrow(() => sender.At(unspecified)); + } + + [Test] + public async Task At_LocalDateTime_ConvertedToUtc_WhenEnabled() + { + var local = DateTime.SpecifyKind(new DateTime(2026, 4, 28, 12, 0, 0), DateTimeKind.Local); + + var frameLocal = await CaptureFirstFrame("convert_local_to_utc=on;", + s => s.Table("t").Column("v", 1L).At(local)); + // A Utc-kind value is never re-converted, so this is the post-conversion reference. + var frameConverted = await CaptureFirstFrame("convert_local_to_utc=on;", + s => s.Table("t").Column("v", 1L).At(local.ToUniversalTime())); + + Assert.That(frameLocal, Is.EqualTo(frameConverted)); + } + + [Test] + public async Task At_LocalDateTime_NotConverted_ByDefault() + { + var local = DateTime.SpecifyKind(new DateTime(2026, 4, 28, 12, 0, 0), DateTimeKind.Local); + + var frameLocal = await CaptureFirstFrame("", + s => s.Table("t").Column("v", 1L).At(local)); + // Default writes the raw wall-clock: identical to the same instant tagged Utc. + var frameRaw = await CaptureFirstFrame("", + s => s.Table("t").Column("v", 1L).At(DateTime.SpecifyKind(local, DateTimeKind.Utc))); + + Assert.That(frameLocal, Is.EqualTo(frameRaw)); + } + + [Test] + public async Task Column_LocalDateTime_ConvertedToUtc_WhenEnabled() + { + var local = DateTime.SpecifyKind(new DateTime(2026, 4, 28, 12, 0, 0), DateTimeKind.Local); + + var frameLocal = await CaptureFirstFrame("convert_local_to_utc=on;", + s => s.Table("t").Column("ts", local).At(DateTime.UnixEpoch)); + var frameConverted = await CaptureFirstFrame("convert_local_to_utc=on;", + s => s.Table("t").Column("ts", local.ToUniversalTime()).At(DateTime.UnixEpoch)); + + Assert.That(frameLocal, Is.EqualTo(frameConverted)); + } + + [Test] + public async Task Column_LocalDateTime_NotConverted_ByDefault() + { + var local = DateTime.SpecifyKind(new DateTime(2026, 4, 28, 12, 0, 0), DateTimeKind.Local); + + var frameLocal = await CaptureFirstFrame("", + s => s.Table("t").Column("ts", local).At(DateTime.UnixEpoch)); + var frameRaw = await CaptureFirstFrame("", + s => s.Table("t").Column("ts", DateTime.SpecifyKind(local, DateTimeKind.Utc)).At(DateTime.UnixEpoch)); + + Assert.That(frameLocal, Is.EqualTo(frameRaw)); + } + + [Test] + public async Task PostTerminal_MutatorsThrow() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildErrorAck(QwpStatusCode.WriteError, 0, "boom"), + }); + await server.StartAsync(); + var sender = NewSender(server, "auto_flush=off;on_server_error=halt;"); + try + { + var qwp = (IQwpWebSocketSender)sender; + + sender.Table("t").Column("v", 1L).At(DateTime.UtcNow); + sender.Send(); + try { await qwp.PingAsync(); } catch { /* expected terminal */ } + + Assert.Throws(() => sender.Truncate()); + Assert.Throws(() => sender.CancelRow()); + Assert.Throws(() => sender.Clear()); + } + finally + { + try { sender.Dispose(); } catch { } + } + } + + private static async Task WaitFor(Func predicate, int timeoutMs = 2000) + { + var deadline = Environment.TickCount64 + timeoutMs; + while (!predicate() && Environment.TickCount64 < deadline) + { + await Task.Delay(20); + } + } + + private static void TryDeleteDirectory(string path) + { + if (Directory.Exists(path)) + { + try { Directory.Delete(path, recursive: true); } catch { } + } + } + + // Non-zero byte count across all SF segment files. A pre-sized, empty segment is all zeros; + // a written envelope ([u32 crc32c][u32 frame_len][frame]) introduces non-zero bytes. + private static long SegmentBytesOnDisk(string sfRoot) + { + if (!Directory.Exists(sfRoot)) return 0; + long nonZero = 0; + foreach (var path in Directory.EnumerateFiles(sfRoot, "*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".sfa", StringComparison.Ordinal)) continue; + foreach (var b in File.ReadAllBytes(path)) + { + if (b != 0) nonZero++; + } + } + return nonZero; + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/QwpWebSocketTransportTests.cs b/src/net-questdb-client-tests/Qwp/QwpWebSocketTransportTests.cs new file mode 100644 index 00000000..e13f42ea --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpWebSocketTransportTests.cs @@ -0,0 +1,531 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Net; +using NUnit.Framework; +using QuestDB.Qwp; +using QuestDB.Enums; +using QuestDB.Utils; +using dummy_http_server; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpWebSocketTransportTests +{ + [Test] + public async Task Handshake_NegotiatesVersion1_AndConnects() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + try + { + Assert.That(transport.IsConnected, Is.True); + Assert.That(transport.NegotiatedVersion, Is.EqualTo(1)); + } + finally + { + await transport.CloseAsync(); + transport.Dispose(); + } + } + + [Test] + public async Task Handshake_ServerReturnsUnsupportedVersion_Throws() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + NegotiatedVersion = "99", + }); + await server.StartAsync(); + + var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + try + { + Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + } + finally + { + transport.Dispose(); + } + } + + [Test] + public async Task Handshake_ServerOmitsVersionHeader_DefaultsToVersion1() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + NegotiatedVersion = null, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + Assert.DoesNotThrowAsync(async () => await transport.ConnectAsync()); + Assert.That(transport.NegotiatedVersion, Is.EqualTo(1)); + } + + [Test] + public async Task Handshake_ParsesMaxBatchSize() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + MaxBatchSize = 131058, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + Assert.That(transport.NegotiatedMaxBatchSize, Is.EqualTo(131058)); + } + + [Test] + public async Task Handshake_NoMaxBatchSizeHeader_DefaultsToZero() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions()); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + Assert.That(transport.NegotiatedMaxBatchSize, Is.EqualTo(0)); + } + + [Test] + public async Task Handshake_ServerRejectsUpgrade_Throws() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.Forbidden, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + Assert.CatchAsync(async () => await transport.ConnectAsync()); + } + + [Test] + public async Task Handshake_Rejected401_ThrowsQwpAuthFailedExceptionPreservingIngressErrorCatch() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + RejectUpgradeWith = HttpStatusCode.Unauthorized, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + var ex = Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + Assert.That(ex!.HttpStatusCode, Is.EqualTo(401)); + Assert.That(ex.Endpoint, Is.EqualTo(server.Uri)); + Assert.That(ex, Is.InstanceOf(), "must remain catchable as IngressError"); + Assert.That(ex.code, Is.EqualTo(ErrorCode.AuthError), "code must remain AuthError"); + } + + [Test] + public async Task Handshake_SendsExpectedQwpHeaders() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + ClientId = "dotnet/test/1.0", + }); + + await transport.ConnectAsync(); + await transport.CloseAsync(); + + Assert.That(server.LastUpgradeHeaders, Is.Not.Null); + Assert.That(server.LastUpgradeHeaders!["X-QWP-Max-Version"], Is.EqualTo("1")); + Assert.That(server.LastUpgradeHeaders["X-QWP-Client-Id"], Is.EqualTo("dotnet/test/1.0")); + } + + [Test] + public async Task Handshake_AuthorizationHeader_IsForwardedWhenSet() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + AuthorizationHeader = "Bearer xyz", + }); + + await transport.ConnectAsync(); + await transport.CloseAsync(); + + Assert.That(server.LastUpgradeHeaders!["Authorization"], Is.EqualTo("Bearer xyz")); + } + + [Test] + public async Task Handshake_RequestDurableAck_OptsInViaHeader() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions { DurableAckEnabled = true }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + RequestDurableAck = true, + }); + + await transport.ConnectAsync(); + await transport.CloseAsync(); + + Assert.That(server.LastUpgradeHeaders!.ContainsKey("X-QWP-Request-Durable-Ack")); + Assert.That(server.LastUpgradeHeaders["X-QWP-Request-Durable-Ack"], Is.EqualTo("true")); + } + + [Test] + public async Task Handshake_RequestDurableAck_ServerDoesNotEcho_ThrowsTerminal() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + RequestDurableAck = true, + }); + + var ex = Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.DurableAckNotSupported)); + } + + [Test] + public async Task Handshake_NoDurableAck_OmitsHeader() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + await transport.CloseAsync(); + + Assert.That(server.LastUpgradeHeaders!.ContainsKey("X-QWP-Request-Durable-Ack"), Is.False); + } + + [Test] + public async Task SendBinary_ServerReceivesFrameVerbatim() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + var payload = new byte[] { 0x51, 0x57, 0x50, 0x31, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; + await transport.SendBinaryAsync(payload); + await transport.CloseAsync(); + + // Wait briefly for the server to drain. + for (var i = 0; i < 50 && server.ReceivedFrames.Count == 0; i++) + { + await Task.Delay(20); + } + + Assert.That(server.ReceivedFrames.Count, Is.EqualTo(1)); + Assert.That(server.ReceivedFrames.First(), Is.EqualTo(payload)); + } + + [Test] + public async Task ReceiveFrame_ServerSendsResponse_ClientGetsBytes() + { + var responseBytes = new byte[] { 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; // OK ack, seq=7 + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => responseBytes, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + await transport.SendBinaryAsync(new byte[] { 0xAA, 0xBB }); + var buf = new byte[64]; + var read = await transport.ReceiveFrameAsync(buf); + await transport.CloseAsync(); + + Assert.That(read, Is.EqualTo(responseBytes.Length)); + Assert.That(buf.AsSpan(0, read).ToArray(), Is.EqualTo(responseBytes)); + } + + [Test] + public async Task ReceiveFrame_GrowableBuffer_ResizesUpToCap() + { + var oversized = new byte[10_000]; + for (var i = 0; i < oversized.Length; i++) oversized[i] = (byte)(i & 0xFF); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => oversized, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + await transport.SendBinaryAsync(new byte[] { 0x01 }); + + var buf = new byte[1024]; + var (read, grown) = await transport.ReceiveFrameAsync(buf, maxBytes: 64 * 1024); + + Assert.That(read, Is.EqualTo(oversized.Length)); + Assert.That(grown.Length, Is.GreaterThanOrEqualTo(oversized.Length)); + Assert.That(grown.AsSpan(0, read).ToArray(), Is.EqualTo(oversized)); + } + + [Test] + public async Task ReceiveFrame_GrowableBuffer_RejectsBeyondCap() + { + var oversized = new byte[10_000]; + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => oversized, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + await transport.SendBinaryAsync(new byte[] { 0x01 }); + + var buf = new byte[256]; + Assert.ThrowsAsync(async () => + await transport.ReceiveFrameAsync(buf, maxBytes: 1024)); + } + + [Test] + public async Task ReceiveFrame_BufferTooSmall_Throws() + { + var oversized = new byte[256]; + Array.Fill(oversized, (byte)0xCD); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => oversized, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + await transport.SendBinaryAsync(new byte[] { 0x01 }); + var smallBuf = new byte[16]; + Assert.ThrowsAsync(async () => await transport.ReceiveFrameAsync(smallBuf)); + } + + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.ProtocolError)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.InvalidMessageType)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.InvalidPayloadData)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.MessageTooBig)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.MandatoryExtension)] + public async Task ReceiveFrame_ProtocolViolationClose_RaisesTypedTerminal( + System.Net.WebSockets.WebSocketCloseStatus status) + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => null!, + CloseAfterFrameCount = 1, + CloseStatus = status, + CloseReason = "boom", + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + await transport.ConnectAsync(); + await transport.SendBinaryAsync(new byte[] { 0x01 }); + + var buf = new byte[64]; + var ex = Assert.ThrowsAsync( + async () => await transport.ReceiveFrameAsync(buf)); + Assert.That(ex!.CloseStatus, Is.EqualTo(status)); + Assert.That(ex.code, Is.EqualTo(ErrorCode.ProtocolViolation)); + Assert.That(ex.Reason, Is.EqualTo("boom")); + Assert.That(ex.Message, Does.Contain($"ws-close[{(int)status}]: boom")); + } + + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.EndpointUnavailable)] + [TestCase(System.Net.WebSockets.WebSocketCloseStatus.InternalServerError)] + public async Task ReceiveFrame_ReconnectEligibleClose_RaisesSocketError( + System.Net.WebSockets.WebSocketCloseStatus status) + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => null!, + CloseAfterFrameCount = 1, + CloseStatus = status, + CloseReason = "bye", + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + await transport.ConnectAsync(); + await transport.SendBinaryAsync(new byte[] { 0x01 }); + + var buf = new byte[64]; + var ex = Assert.ThrowsAsync( + async () => await transport.ReceiveFrameAsync(buf)); + Assert.That(ex, Is.Not.InstanceOf()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.SocketError)); + } + + [Test] + public async Task DumpMode_CapturesOutgoingAndIncoming() + { + var responseBytes = new byte[] { 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => responseBytes, + }); + await server.StartAsync(); + + using var dump = new MemoryStream(); + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + DumpStream = dump, + }); + + await transport.ConnectAsync(); + var sent = new byte[] { 0x42, 0x43, 0x44 }; + await transport.SendBinaryAsync(sent); + var buf = new byte[64]; + await transport.ReceiveFrameAsync(buf); + await transport.CloseAsync(); + + // Dump format: [direction byte][uint32 LE length][bytes]. + var bytes = dump.ToArray(); + + // First record: 'S' + sent. + Assert.That(bytes[0], Is.EqualTo((byte)'S')); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(1, 4)), Is.EqualTo(sent.Length)); + Assert.That(bytes.AsSpan(5, sent.Length).ToArray(), Is.EqualTo(sent)); + + // Second record: 'R' + responseBytes. + var pos = 5 + sent.Length; + Assert.That(bytes[pos], Is.EqualTo((byte)'R')); + Assert.That(BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(pos + 1, 4)), Is.EqualTo(responseBytes.Length)); + Assert.That(bytes.AsSpan(pos + 5, responseBytes.Length).ToArray(), Is.EqualTo(responseBytes)); + } + + [Test] + public async Task CloseAsync_IsIdempotent() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + await transport.CloseAsync(); + Assert.DoesNotThrowAsync(async () => await transport.CloseAsync()); + } + + [Test] + public async Task SendBinary_OnDisposedTransport_Throws() + { + await using var server = new DummyQwpServer(); + await server.StartAsync(); + + var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + await transport.ConnectAsync(); + transport.Dispose(); + + Assert.ThrowsAsync(async () => await transport.SendBinaryAsync(new byte[] { 1 })); + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/QwpWireFormatVectorsTests.cs b/src/net-questdb-client-tests/Qwp/QwpWireFormatVectorsTests.cs new file mode 100644 index 00000000..25fa6578 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpWireFormatVectorsTests.cs @@ -0,0 +1,220 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Globalization; +using System.Numerics; +using NUnit.Framework; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp; + +/// +/// Pinned byte-exact wire-format vectors. Any change to a column-level encoder must +/// keep these stable or it will break interop with other clients on the same connection. +/// +[TestFixture] +public class QwpWireFormatVectorsTests +{ + [TestCase("0", (byte)0, + new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase("123.456", (byte)3, + new byte[] { 0x40, 0xE2, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase("-1", (byte)0, + new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF })] + [TestCase("-79228162514264337593543950335", (byte)0, + new byte[] { 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF })] + [TestCase("79228162514264337593543950335", (byte)0, + new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00 })] + public void Decimal128_Vector_PinnedBytes(string decimalText, byte expectedScale, byte[] expectedBytes) + { + var col = new QwpColumn("d", 0); + col.AppendDecimal128(decimal.Parse(decimalText, CultureInfo.InvariantCulture)); + + Assert.That(col.DecimalScale, Is.EqualTo(expectedScale)); + Assert.That(col.FixedLen, Is.EqualTo(16)); + Assert.That(col.FixedData!.AsSpan(0, 16).ToArray(), Is.EqualTo(expectedBytes)); + } + + [TestCase("00112233-4455-6677-8899-aabbccddeeff", + new byte[] { 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00 })] + [TestCase("00000000-0000-0000-0000-000000000000", + new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase("ffffffff-ffff-ffff-ffff-ffffffffffff", + new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF })] + [TestCase("01020304-0506-0708-090a-0b0c0d0e0f10", + new byte[] { 0x10, 0x0F, 0x0E, 0x0D, 0x0C, 0x0B, 0x0A, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 })] + public void Uuid_Vector_PinnedBytes(string guidText, byte[] expectedBytes) + { + var col = new QwpColumn("u", 0); + col.AppendUuid(Guid.Parse(guidText)); + + Assert.That(col.FixedLen, Is.EqualTo(16)); + Assert.That(col.FixedData!.AsSpan(0, 16).ToArray(), Is.EqualTo(expectedBytes)); + } + + [TestCase("0", + new byte[] { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase("1", + new byte[] { + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase("00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF", + new byte[] { + 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00, + 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00 })] + [TestCase("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + new byte[] { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF })] + public void Long256_Vector_PinnedBytes(string hexOrDecimal, byte[] expectedBytes) + { + // Test cases above are either short decimals ("0", "1") or full 64-char hex. + var value = hexOrDecimal.Length > 4 + ? BigInteger.Parse("0" + hexOrDecimal, NumberStyles.HexNumber, CultureInfo.InvariantCulture) + : BigInteger.Parse(hexOrDecimal, CultureInfo.InvariantCulture); + + var col = new QwpColumn("l", 0); + col.AppendLong256(value); + + Assert.That(col.FixedLen, Is.EqualTo(32)); + Assert.That(col.FixedData!.AsSpan(0, 32).ToArray(), Is.EqualTo(expectedBytes)); + } + + [TestCase(new byte[] { }, 0x00000000u)] + [TestCase(new byte[] { 0x00 }, 0x527D5351u)] + [TestCase(new byte[] { (byte)'a' }, 0xC1D04330u)] + [TestCase(new byte[] { 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39 }, 0xE3069283u)] + [TestCase(new byte[] { 0x68, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x77, 0x6F, 0x72, 0x6C, 0x64 }, 0xC99465AAu)] + public void Crc32C_Vector_MatchesStandard(byte[] input, uint expected) + { + Assert.That(QwpCrc32C.Compute(input), Is.EqualTo(expected)); + } + + [TestCase((sbyte)0, new byte[] { 0x00 })] + [TestCase((sbyte)1, new byte[] { 0x01 })] + [TestCase((sbyte)-1, new byte[] { 0xFF })] + [TestCase((sbyte)127, new byte[] { 0x7F })] + [TestCase((sbyte)-128, new byte[] { 0x80 })] + public void Byte_Vector_PinnedBytes(sbyte value, byte[] expected) + { + var col = new QwpColumn("b", 0); + col.AppendByte(value); + Assert.That(col.FixedLen, Is.EqualTo(1)); + Assert.That(col.FixedData!.AsSpan(0, 1).ToArray(), Is.EqualTo(expected)); + } + + [TestCase((short)0, new byte[] { 0x00, 0x00 })] + [TestCase((short)1, new byte[] { 0x01, 0x00 })] + [TestCase((short)-1, new byte[] { 0xFF, 0xFF })] + [TestCase(short.MaxValue, new byte[] { 0xFF, 0x7F })] + [TestCase(short.MinValue, new byte[] { 0x00, 0x80 })] + public void Short_Vector_PinnedBytes(short value, byte[] expected) + { + var col = new QwpColumn("s", 0); + col.AppendShort(value); + Assert.That(col.FixedLen, Is.EqualTo(2)); + Assert.That(col.FixedData!.AsSpan(0, 2).ToArray(), Is.EqualTo(expected)); + } + + [TestCase(0, new byte[] { 0x00, 0x00, 0x00, 0x00 })] + [TestCase(1, new byte[] { 0x01, 0x00, 0x00, 0x00 })] + [TestCase(-1, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF })] + [TestCase(int.MaxValue, new byte[] { 0xFF, 0xFF, 0xFF, 0x7F })] + [TestCase(int.MinValue, new byte[] { 0x00, 0x00, 0x00, 0x80 })] + public void Int_Vector_PinnedBytes(int value, byte[] expected) + { + var col = new QwpColumn("i", 0); + col.AppendInt(value); + Assert.That(col.FixedLen, Is.EqualTo(4)); + Assert.That(col.FixedData!.AsSpan(0, 4).ToArray(), Is.EqualTo(expected)); + } + + [TestCase(0L, new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase(1L, new byte[] { 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 })] + [TestCase(-1L, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF })] + [TestCase(long.MaxValue, new byte[] { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F })] + [TestCase(long.MinValue, new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80 })] + public void Long_Vector_PinnedBytes(long value, byte[] expected) + { + var col = new QwpColumn("l", 0); + col.AppendLong(value); + Assert.That(col.FixedLen, Is.EqualTo(8)); + Assert.That(col.FixedData!.AsSpan(0, 8).ToArray(), Is.EqualTo(expected)); + } + + [TestCase((char)0, new byte[] { 0x00, 0x00 })] + [TestCase('A', new byte[] { 0x41, 0x00 })] + [TestCase('中', new byte[] { 0x2D, 0x4E })] + [TestCase((char)0xFFFF, new byte[] { 0xFF, 0xFF })] + public void Char_Vector_PinnedBytes(char value, byte[] expected) + { + var col = new QwpColumn("c", 0); + col.AppendChar(value); + Assert.That(col.FixedLen, Is.EqualTo(2)); + Assert.That(col.FixedData!.AsSpan(0, 2).ToArray(), Is.EqualTo(expected)); + } + + [Test] + public void Boolean_Vector_BitPackedLsbFirst() + { + // 11 booleans: positions 0,2,4,6,8,10 are true → byte 0 = 0b01010101 = 0x55, byte 1 = 0b00000101 = 0x05. + var col = new QwpColumn("b", 0); + for (var i = 0; i < 11; i++) col.AppendBool(i % 2 == 0); + Assert.That(col.BoolData, Is.Not.Null); + Assert.That(col.BoolData!.AsSpan(0, 2).ToArray(), Is.EqualTo(new byte[] { 0x55, 0x05 })); + } + + [Test] + public void Float_Vector_PinnedBytes() + { + var col = new QwpColumn("f", 0); + col.AppendFloat(1.0f); + col.AppendFloat(-1.0f); + col.AppendFloat(0.0f); + Assert.That(col.FixedLen, Is.EqualTo(12)); + Assert.That(col.FixedData!.AsSpan(0, 12).ToArray(), Is.EqualTo(new byte[] + { + 0x00, 0x00, 0x80, 0x3F, + 0x00, 0x00, 0x80, 0xBF, + 0x00, 0x00, 0x00, 0x00, + })); + } + + [Test] + public void Double_Vector_PinnedBytes() + { + var col = new QwpColumn("d", 0); + col.AppendDouble(1.0); + col.AppendDouble(-1.0); + Assert.That(col.FixedLen, Is.EqualTo(16)); + Assert.That(col.FixedData!.AsSpan(0, 16).ToArray(), Is.EqualTo(new byte[] + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xBF, + })); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpAckWatermarkTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpAckWatermarkTests.cs new file mode 100644 index 00000000..4ee65bb4 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpAckWatermarkTests.cs @@ -0,0 +1,294 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; + +namespace QuestDB.Tests.Qwp.Sf; + +[TestFixture] +public class QwpAckWatermarkTests +{ + private string _tempDir = null!; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "qwp-akw-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { } + } + + [Test] + public void FreshFile_ReadsInvalid() + { + using var wm = QwpAckWatermark.Open(_tempDir)!; + Assert.That(wm, Is.Not.Null); + Assert.That(wm.Read(), Is.EqualTo(QwpAckWatermark.Invalid)); + } + + [Test] + public void Write_RoundTripsAcrossReopen() + { + using (var wm = QwpAckWatermark.Open(_tempDir)!) + { + wm.Write(12345L); + Assert.That(wm.Read(), Is.EqualTo(12345L)); + } + + using (var wm = QwpAckWatermark.Open(_tempDir)!) + { + Assert.That(wm.Read(), Is.EqualTo(12345L)); + } + } + + [Test] + public void Flush_PersistsWrite_AcrossReopen() + { + using (var wm = QwpAckWatermark.Open(_tempDir)!) + { + wm.Write(987654321L); + wm.Flush(); + } + + using var reopened = QwpAckWatermark.Open(_tempDir)!; + Assert.That(reopened.Read(), Is.EqualTo(987654321L)); + } + + [Test] + public void Flush_OnFreshFile_DoesNotThrow() + { + using var wm = QwpAckWatermark.Open(_tempDir)!; + Assert.That(() => wm.Flush(), Throws.Nothing); + } + + [Test] + public void Flush_AfterDispose_DoesNotThrow() + { + var wm = QwpAckWatermark.Open(_tempDir)!; + wm.Write(7L); + wm.Dispose(); + Assert.That(() => wm.Flush(), Throws.Nothing); + } + + [Test] + public void Write_PersistsMonotonicAdvances() + { + using var wm = QwpAckWatermark.Open(_tempDir)!; + wm.Write(1L); + wm.Write(2L); + wm.Write(100L); + Assert.That(wm.Read(), Is.EqualTo(100L)); + } + + [Test] + public void FileLayout_Is16Bytes_WithMagicAndFsn() + { + using (var wm = QwpAckWatermark.Open(_tempDir)!) + { + wm.Write(0x0102030405060708L); + } + + var path = Path.Combine(_tempDir, QwpAckWatermark.FileName); + var bytes = File.ReadAllBytes(path); + Assert.That(bytes.Length, Is.EqualTo(16)); + // Magic "AKW1" little-endian at offset 0. + Assert.That(bytes[0], Is.EqualTo(0x41)); + Assert.That(bytes[1], Is.EqualTo(0x4B)); + Assert.That(bytes[2], Is.EqualTo(0x57)); + Assert.That(bytes[3], Is.EqualTo(0x31)); + // Reserved bytes [4..7] are zero. + Assert.That(bytes[4], Is.EqualTo(0)); + Assert.That(bytes[5], Is.EqualTo(0)); + Assert.That(bytes[6], Is.EqualTo(0)); + Assert.That(bytes[7], Is.EqualTo(0)); + // FSN little-endian at offset 8. + Assert.That(bytes[8], Is.EqualTo(0x08)); + Assert.That(bytes[9], Is.EqualTo(0x07)); + Assert.That(bytes[10], Is.EqualTo(0x06)); + Assert.That(bytes[11], Is.EqualTo(0x05)); + Assert.That(bytes[12], Is.EqualTo(0x04)); + Assert.That(bytes[13], Is.EqualTo(0x03)); + Assert.That(bytes[14], Is.EqualTo(0x02)); + Assert.That(bytes[15], Is.EqualTo(0x01)); + } + + [Test] + public void RemoveOrphan_DeletesFile() + { + using (var wm = QwpAckWatermark.Open(_tempDir)!) + { + wm.Write(99L); + } + var path = Path.Combine(_tempDir, QwpAckWatermark.FileName); + Assert.That(File.Exists(path), Is.True); + + QwpAckWatermark.RemoveOrphan(_tempDir); + Assert.That(File.Exists(path), Is.False); + } + + [Test] + public void RemoveOrphan_IsBestEffort_OnMissingFile() + { + QwpAckWatermark.RemoveOrphan(_tempDir); + Assert.That(File.Exists(Path.Combine(_tempDir, QwpAckWatermark.FileName)), Is.False); + } + + [Test] + public void Open_PreservesWrongSizedFile_ReturnsNull() + { + var path = Path.Combine(_tempDir, QwpAckWatermark.FileName); + var bogus = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }; + File.WriteAllBytes(path, bogus); + + var wm = QwpAckWatermark.Open(_tempDir); + Assert.That(wm, Is.Null); + Assert.That(new FileInfo(path).Length, Is.EqualTo(bogus.Length)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bogus)); + } + + [Test] + public void Open_TreatsZeroFilledFile_AsInvalid() + { + var path = Path.Combine(_tempDir, QwpAckWatermark.FileName); + File.WriteAllBytes(path, new byte[16]); + + using var wm = QwpAckWatermark.Open(_tempDir)!; + Assert.That(wm.Read(), Is.EqualTo(QwpAckWatermark.Invalid)); + } + + [Test] + public void Read_AfterDispose_ReturnsInvalid() + { + var wm = QwpAckWatermark.Open(_tempDir)!; + wm.Write(42L); + wm.Dispose(); + Assert.That(wm.Read(), Is.EqualTo(QwpAckWatermark.Invalid)); + } + + [Test] + public void DoubleDispose_DoesNotThrow() + { + var wm = QwpAckWatermark.Open(_tempDir)!; + wm.Dispose(); + Assert.That(() => wm.Dispose(), Throws.Nothing); + } + + [Test] + public void Engine_RefinesAckedFsn_FromWatermark() + { + var slotDir = Path.Combine(_tempDir, "slot"); + Directory.CreateDirectory(slotDir); + + using (var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096)) + { + Assert.That(ring.TryAppend(new byte[] { 0x00 }), Is.True); + Assert.That(ring.TryAppend(new byte[] { 0x01 }), Is.True); + Assert.That(ring.TryAppend(new byte[] { 0x02 }), Is.True); + } + + using (var wm = QwpAckWatermark.Open(slotDir)!) + { + wm.Write(1L); + } + + using var slotLock = QwpSlotLock.Acquire(slotDir); + var ring2 = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + Assert.That(ring2.OldestFsn, Is.EqualTo(0)); + Assert.That(ring2.NextFsn, Is.EqualTo(3)); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)); + var wm2 = QwpAckWatermark.Open(slotDir); + using var engine = new QwpCursorSendEngine( + slotLock, ring2, () => null!, policy, + TimeSpan.FromSeconds(5), InitialConnectMode.off, + ackWatermark: wm2); + + Assert.That(engine.AckedFsn, Is.EqualTo(2)); + } + + [Test] + public void Engine_FallsBackToSegmentSeed_WhenWatermarkExceedsPublished() + { + var slotDir = Path.Combine(_tempDir, "slot"); + Directory.CreateDirectory(slotDir); + + using (var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096)) + { + Assert.That(ring.TryAppend(new byte[] { 0x00 }), Is.True); + Assert.That(ring.TryAppend(new byte[] { 0x01 }), Is.True); + } + + using (var wm = QwpAckWatermark.Open(slotDir)!) + { + wm.Write(99L); + } + + using var slotLock = QwpSlotLock.Acquire(slotDir); + var ring2 = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + Assert.That(ring2.NextFsn, Is.EqualTo(2)); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)); + var wm2 = QwpAckWatermark.Open(slotDir); + using var engine = new QwpCursorSendEngine( + slotLock, ring2, () => null!, policy, + TimeSpan.FromSeconds(5), InitialConnectMode.off, + ackWatermark: wm2); + + Assert.That(engine.AckedFsn, Is.EqualTo(ring2.OldestFsn)); + } + + [Test] + public void Engine_UsesSegmentSeed_WhenNoWatermark() + { + var slotDir = Path.Combine(_tempDir, "slot"); + Directory.CreateDirectory(slotDir); + + using (var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096)) + { + Assert.That(ring.TryAppend(new byte[] { 0x00 }), Is.True); + } + + using var slotLock = QwpSlotLock.Acquire(slotDir); + var ring2 = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)); + using var engine = new QwpCursorSendEngine( + slotLock, ring2, () => null!, policy, + TimeSpan.FromSeconds(5), InitialConnectMode.off, + ackWatermark: null); + + Assert.That(engine.AckedFsn, Is.EqualTo(ring2.OldestFsn)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerPoolTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerPoolTests.cs new file mode 100644 index 00000000..50bed5b7 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerPoolTests.cs @@ -0,0 +1,448 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpBackgroundDrainerPoolTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-pool-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Test] + public void Constructor_NonPositiveConcurrency_Throws() + { + var drainer = new SuccessDrainer(); + Assert.Throws(() => new QwpBackgroundDrainerPool(0, drainer)); + Assert.Throws(() => new QwpBackgroundDrainerPool(-1, drainer)); + } + + [Test] + public async Task Enqueue_RunsDrainAndReleasesLock() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new SuccessDrainer(); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(drainer.Drained, Has.Member(slotDir)); + // Lock must have been disposed → can re-acquire. + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + // No .failed sentinel on success. + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.False); + } + + [Test] + public async Task Enqueue_ReplayImpossibleError_DropsFailedSentinelAndReleasesLock() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new ThrowingDrainer(new QwpException(QwpStatusCode.SchemaMismatch, sequence: 0, message: "schema-mismatch")); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.True); + var sentinel = await File.ReadAllTextAsync(Path.Combine(slotDir, ".failed")); + Assert.That(sentinel, Does.Contain("schema-mismatch")); + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + } + + [Test] + public async Task Enqueue_ServerFlushError_DropsFailedSentinel() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new ThrowingDrainer(new IngressError(ErrorCode.ServerFlushError, "drain timeout")); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.True); + } + + [Test] + public async Task Enqueue_GenericException_DropsFailedSentinel() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new ThrowingDrainer(new InvalidOperationException("transport-glitch")); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.True); + } + + [Test] + public async Task Enqueue_QwpAuthError_DropsFailedSentinel() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new ThrowingDrainer(new QwpException(QwpStatusCode.SecurityError, 0, "auth")); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.True); + } + + [Test] + public async Task Enqueue_DrainTimeout_LeavesNoSentinelAndReleasesLockForReadoption() + { + // A drain that exceeds its budget throws TimeoutException (QwpCursorSendEngine.FlushAsync). + // This is transient — a slow/unreachable server or a backlog larger than the window — so the + // orphan's recoverable frames must NOT be permanently quarantined with a .failed sentinel. + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new ThrowingDrainer(new TimeoutException( + "close_flush_timeout (5000 ms) expired with un-acked frames pending")); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.False, + "a transient drain timeout must not write a permanent .failed sentinel"); + // Lock released → QwpOrphanScanner can re-adopt the slot on a later sweep. + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + } + + [Test] + public async Task Enqueue_ReconnectBudgetExhaustedFromTransientOutage_LeavesNoSentinel() + { + // Reconnect-budget exhaustion re-wraps the connectivity cause as IngressError(..., inner) + // (QwpCursorSendEngine.WrapTerminalForProducer). A non-terminal inner means the outage was + // transient → retryable, so no sentinel. + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var transientCause = new IOException("connection reset by peer"); + var drainer = new ThrowingDrainer(new IngressError(ErrorCode.ServerFlushError, + "QWP cursor engine has terminally failed; see inner exception", transientCause)); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.False, + "a transient outage that exhausted the reconnect budget must not quarantine the slot"); + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + } + + [Test] + public async Task Enqueue_WrappedDeterministicTerminal_DropsFailedSentinel() + { + // A deterministic terminal (here auth) is wrapped the same way, but its inner IS terminal — + // a retry will fail identically, so the slot is quarantined. + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var terminalCause = new IngressError(ErrorCode.AuthError, "401 unauthorized"); + var drainer = new ThrowingDrainer(new IngressError(ErrorCode.AuthError, + "QWP cursor engine has terminally failed; see inner exception", terminalCause)); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock); + await pool.WaitForAllAsync(); + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.True); + } + + [Test] + public async Task Enqueue_RespectsConcurrencyCap() + { + const int cap = 2; + const int submissions = 6; + + var drainer = new GatedDrainer(); + using var pool = new QwpBackgroundDrainerPool(cap, drainer); + + var locks = new List(); + for (var i = 0; i < submissions; i++) + { + locks.Add(QwpSlotLock.Acquire(Path.Combine(_root, $"slot-{i}"))); + } + + foreach (var l in locks) + { + pool.Enqueue(l); + } + + // Wait until exactly `cap` drains are in flight, then verify the rest are queued. + await drainer.WaitForInFlightAsync(cap); + await Task.Delay(50); + Assert.That(drainer.PeakInFlight, Is.LessThanOrEqualTo(cap)); + Assert.That(drainer.InFlight, Is.EqualTo(cap)); + + drainer.ReleaseAll(); + await pool.WaitForAllAsync(); + + Assert.That(drainer.PeakInFlight, Is.LessThanOrEqualTo(cap)); + Assert.That(drainer.CompletedCount, Is.EqualTo(submissions)); + } + + [Test] + public async Task Enqueue_CooperativelyCancelled_ReleasesLockWithoutSentinel() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new GatedDrainer(); + using var cts = new CancellationTokenSource(); + + using var pool = new QwpBackgroundDrainerPool(2, drainer); + pool.Enqueue(slotLock, cts.Token); + await drainer.WaitForInFlightAsync(1); + + cts.Cancel(); + drainer.ReleaseAll(); + + try { await pool.WaitForAllAsync(); } + catch (OperationCanceledException) { /* expected */ } + + // Cancellation must not drop a sentinel — the next sender startup retries the slot. + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.False); + // Lock is released. + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + } + + [Test] + public async Task ConcurrentEnqueue_AllDrainsComplete_NoBookkeepingRace() + { + const int slotCount = 64; + const int concurrency = 4; + + var drainer = new SuccessDrainer(); + using var pool = new QwpBackgroundDrainerPool(concurrency, drainer); + + var locks = Enumerable.Range(0, slotCount) + .Select(i => QwpSlotLock.Acquire(Path.Combine(_root, $"slot-{i}"))) + .ToList(); + + Parallel.ForEach(locks, l => pool.Enqueue(l)); + await pool.WaitForAllAsync(); + + Assert.That(drainer.Drained, Has.Count.EqualTo(slotCount)); + Assert.That(drainer.Drained.ToHashSet(), Has.Count.EqualTo(slotCount), + "every slot must be drained exactly once"); + } + + [Test] + public void Dispose_WedgedDrainer_StillReleasesSlotLock() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new GatedDrainer(); + + var pool = new QwpBackgroundDrainerPool(2, drainer, shutdownWait: TimeSpan.FromMilliseconds(50)); + pool.Enqueue(slotLock); + + pool.Dispose(); + + // Slot lock must be released even though the drainer never returned. + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + + drainer.ReleaseAll(); + } + + [Test] + public async Task Dispose_WedgedDrainerThrowsLater_NoSentinelWrittenToEvictedSlot() + { + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var drainer = new GatedThrowingDrainer(); + var pool = new QwpBackgroundDrainerPool(2, drainer, shutdownWait: TimeSpan.FromMilliseconds(50)); + pool.Enqueue(slotLock); + Assert.That(drainer.DrainStarted.Wait(TimeSpan.FromSeconds(2)), Is.True); + + pool.Dispose(); + using var newOwner = QwpSlotLock.Acquire(slotDir); + + drainer.AllowThrow.Set(); + var deadline = DateTime.UtcNow.AddSeconds(2); + while (drainer.Completed.CurrentCount > 0 && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + + Assert.That(File.Exists(Path.Combine(slotDir, ".failed")), Is.False); + } + + [Test] + public void Enqueue_AfterDispose_Throws() + { + var pool = new QwpBackgroundDrainerPool(2, new SuccessDrainer()); + pool.Dispose(); + + var slotLock = QwpSlotLock.Acquire(Path.Combine(_root, "slot")); + try + { + Assert.Throws(() => pool.Enqueue(slotLock)); + } + finally + { + slotLock.Dispose(); + } + } + + private sealed class SuccessDrainer : IQwpSlotDrainer + { + private readonly List _drained = new(); + private readonly object _lock = new(); + + public IReadOnlyList Drained + { + get + { + lock (_lock) { return _drained.ToArray(); } + } + } + + public Task DrainAsync(string slotDirectory, CancellationToken cancellationToken) + { + lock (_lock) { _drained.Add(slotDirectory); } + return Task.CompletedTask; + } + } + + private sealed class ThrowingDrainer : IQwpSlotDrainer + { + private readonly Exception _ex; + public ThrowingDrainer(Exception ex) => _ex = ex; + public Task DrainAsync(string slotDirectory, CancellationToken cancellationToken) => Task.FromException(_ex); + } + + private sealed class GatedThrowingDrainer : IQwpSlotDrainer + { + public ManualResetEventSlim DrainStarted { get; } = new(); + public ManualResetEventSlim AllowThrow { get; } = new(); + public CountdownEvent Completed { get; } = new(1); + + public Task DrainAsync(string slotDirectory, CancellationToken cancellationToken) + { + return Task.Run(() => + { + DrainStarted.Set(); + AllowThrow.Wait(); + try + { + throw new InvalidOperationException("wedged drainer woke up and threw"); + } + finally + { + Completed.Signal(); + } + }); + } + } + + private sealed class GatedDrainer : IQwpSlotDrainer + { + private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _inFlight; + private int _peakInFlight; + private int _completedCount; + + public int InFlight => Volatile.Read(ref _inFlight); + public int PeakInFlight => Volatile.Read(ref _peakInFlight); + public int CompletedCount => Volatile.Read(ref _completedCount); + + public async Task DrainAsync(string slotDirectory, CancellationToken cancellationToken) + { + var current = Interlocked.Increment(ref _inFlight); + UpdatePeak(current); + try + { + await _gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _completedCount); + } + finally + { + Interlocked.Decrement(ref _inFlight); + } + } + + public void ReleaseAll() => _gate.TrySetResult(true); + + public async Task WaitForInFlightAsync(int target) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (Volatile.Read(ref _inFlight) < target) + { + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"only {InFlight} of {target} drains in flight"); + } + + await Task.Delay(10); + } + } + + private void UpdatePeak(int current) + { + int peak; + do + { + peak = Volatile.Read(ref _peakInFlight); + if (current <= peak) + { + return; + } + } + while (Interlocked.CompareExchange(ref _peakInFlight, current, peak) != peak); + } + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerTests.cs new file mode 100644 index 00000000..614d3389 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerTests.cs @@ -0,0 +1,245 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Threading.Channels; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpBackgroundDrainerTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-bgdrain-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + } + + [Test] + public async Task DrainAsync_SeededSlot_FullyDrainsAndCleansUp() + { + var slotDir = Path.Combine(_root, "orphan"); + SeedSlot(slotDir, payloads: new byte[][] { new byte[] { 1 }, new byte[] { 2 }, new byte[] { 3 } }); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(40), TimeSpan.FromSeconds(2)); + var drainer = new QwpBackgroundDrainer( + transportFactory: () => new StubTransport(), + reconnectPolicy: policy, + segmentCapacity: 4096, + drainTimeout: TimeSpan.FromSeconds(5)); + + await drainer.DrainAsync(slotDir, CancellationToken.None); + + Assert.That(Directory.GetFiles(slotDir, "sf-*.sfa"), Is.Empty, + "drainer must unlink fully-acked segment files before returning"); + } + + [Test] + public async Task DrainAsync_EmptySlot_NoOp() + { + var slotDir = Path.Combine(_root, "empty"); + Directory.CreateDirectory(slotDir); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(40), TimeSpan.FromSeconds(2)); + var drainer = new QwpBackgroundDrainer( + transportFactory: () => new StubTransport(), + reconnectPolicy: policy, + segmentCapacity: 4096, + drainTimeout: TimeSpan.FromSeconds(5)); + + Assert.DoesNotThrowAsync(async () => await drainer.DrainAsync(slotDir, CancellationToken.None)); + } + + [Test] + public async Task DrainAsync_EmptyOrphanSegmentFiles_AreUnlinkedToPreventScannerChurn() + { + var slotDir = Path.Combine(_root, "empty-files"); + Directory.CreateDirectory(slotDir); + + var fakeSegment = Path.Combine(slotDir, "sf-0000000000000000.sfa"); + File.WriteAllBytes(fakeSegment, new byte[64]); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(40), TimeSpan.FromSeconds(2)); + var drainer = new QwpBackgroundDrainer( + transportFactory: () => new StubTransport(), + reconnectPolicy: policy, + segmentCapacity: 4096, + drainTimeout: TimeSpan.FromSeconds(5)); + + await drainer.DrainAsync(slotDir, CancellationToken.None); + + Assert.That(Directory.GetFiles(slotDir, "sf-*.sfa"), Is.Empty, + "empty segment files must be unlinked so the scanner doesn't loop adopting them"); + } + + [Test] + public async Task EndToEnd_ScannerPoolDrainer_AdoptsAndCleansMultipleOrphans() + { + var slotA = Path.Combine(_root, "crashed-a"); + var slotB = Path.Combine(_root, "crashed-b"); + var slotC = Path.Combine(_root, "crashed-c"); + SeedSlot(slotA, payloads: new byte[][] { new byte[] { 10 } }); + SeedSlot(slotB, payloads: new byte[][] { new byte[] { 20 }, new byte[] { 21 } }); + SeedSlot(slotC, payloads: new byte[][] { new byte[] { 30 } }); + + // .failed sentinel marks a slot the scanner must skip. + var slotFailed = Path.Combine(_root, "crashed-failed"); + SeedSlot(slotFailed, payloads: new byte[][] { new byte[] { 99 } }); + await File.WriteAllTextAsync(Path.Combine(slotFailed, ".failed"), "prior crash"); + + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(40), TimeSpan.FromSeconds(2)); + var drainer = new QwpBackgroundDrainer( + transportFactory: () => new StubTransport(), + reconnectPolicy: policy, + segmentCapacity: 4096, + drainTimeout: TimeSpan.FromSeconds(5)); + + using var pool = new QwpBackgroundDrainerPool(maxConcurrent: 2, drainer); + foreach (var l in QwpOrphanScanner.ClaimOrphans(_root, ourSenderId: "live-sender")) + { + pool.Enqueue(l); + } + + await pool.WaitForAllAsync(); + + Assert.That(Directory.GetFiles(slotA, "sf-*.sfa"), Is.Empty); + Assert.That(Directory.GetFiles(slotB, "sf-*.sfa"), Is.Empty); + Assert.That(Directory.GetFiles(slotC, "sf-*.sfa"), Is.Empty); + Assert.That(File.Exists(Path.Combine(slotFailed, ".failed")), Is.True); + Assert.That(Directory.GetFiles(slotFailed, "sf-*.sfa"), Is.Not.Empty); + } + + [Test] + public async Task DrainAsync_TransientWireFailure_ReconnectsAndCompletes() + { + var slotDir = Path.Combine(_root, "flaky"); + SeedSlot(slotDir, payloads: new byte[][] { new byte[] { 1 }, new byte[] { 2 }, new byte[] { 3 } }); + + var stubsBuilt = 0; + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(40), TimeSpan.FromSeconds(2)); + var drainer = new QwpBackgroundDrainer( + transportFactory: () => + { + var idx = Interlocked.Increment(ref stubsBuilt); + var s = new StubTransport(); + if (idx == 1) + { + // First connection succeeds, but the second send throws — engine reconnects. + var sendCount = 0; + s.OnSend = _ => + { + sendCount++; + if (sendCount == 1) return DefaultOk(0); + throw new IngressError(ErrorCode.SocketError, "broken pipe"); + }; + } + return s; + }, + reconnectPolicy: policy, + segmentCapacity: 4096, + drainTimeout: TimeSpan.FromSeconds(5)); + + await drainer.DrainAsync(slotDir, CancellationToken.None); + + Assert.That(stubsBuilt, Is.GreaterThanOrEqualTo(2), + "drainer must reconnect after transient wire failure"); + Assert.That(Directory.GetFiles(slotDir, "sf-*.sfa"), Is.Empty); + } + + private static void SeedSlot(string slotDir, byte[][] payloads) + { + Directory.CreateDirectory(slotDir); + using var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + foreach (var p in payloads) + { + Assert.That(ring.TryAppend(p), Is.True); + } + } + + private static byte[] DefaultOk(long seq) + { + var buf = new byte[11]; + buf[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(1, 8), seq); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(9, 2), 0); + return buf; + } + + private sealed class StubTransport : IQwpCursorTransport + { + public Func? OnSend; + public (string Host, int Port)? Endpoint { get; set; } = ("stub", 0); + private readonly Channel _acks = Channel.CreateUnbounded(); + private int _autoSeq; + + public Task ConnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + byte[] ack; + if (OnSend is not null) + { + ack = OnSend(data.ToArray()); + } + else + { + ack = DefaultOk(Interlocked.Increment(ref _autoSeq) - 1); + } + + await _acks.Writer.WriteAsync(ack, cancellationToken).ConfigureAwait(false); + } + + public async Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken) + { + var ack = await _acks.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + ack.CopyTo(destination.Span); + return ack.Length; + } + + public Task CloseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() => _acks.Writer.TryComplete(); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpCrc32CTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpCrc32CTests.cs new file mode 100644 index 00000000..6af73825 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCrc32CTests.cs @@ -0,0 +1,167 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpCrc32CTests +{ + [Test] + public void Compute_StandardVector_Matches() + { + // Well-known test vector from RFC 3720 / iSCSI, also used by Intel's CRC32C SSE 4.2 docs. + var input = Encoding.ASCII.GetBytes("123456789"); + Assert.That(QwpCrc32C.Compute(input), Is.EqualTo(0xE3069283u)); + } + + [Test] + public void Compute_EmptyInput_IsZero() + { + // CRC32C of empty input: standard produces 0 (after final XOR). + Assert.That(QwpCrc32C.Compute(ReadOnlySpan.Empty), Is.EqualTo(0u)); + } + + [Test] + public void Compute_SingleByte_KnownVector() + { + // CRC32C of a single 'a' (0x61) byte. + // Cross-checked against reference impls: 0xC1D04330. + var input = new byte[] { (byte)'a' }; + Assert.That(QwpCrc32C.Compute(input), Is.EqualTo(0xC1D04330u)); + } + + [Test] + public void Compute_AllZeros_KnownLengths() + { + // For all-zero inputs, CRC32C also produces well-known values. + // 32 zero bytes: 0x8A9136AA per Botan / Crypto++ test vectors. + var input = new byte[32]; + Assert.That(QwpCrc32C.Compute(input), Is.EqualTo(0x8A9136AAu)); + } + + [Test] + public void Compute_AllOnes_32Bytes() + { + // 32 0xFF bytes: 0x62A8AB43. + var input = new byte[32]; + Array.Fill(input, (byte)0xFF); + Assert.That(QwpCrc32C.Compute(input), Is.EqualTo(0x62A8AB43u)); + } + + [Test] + public void Compute_Chaining_EquivalentToSingleCall() + { + var rnd = new Random(0xCAFE); + var data = new byte[1000]; + rnd.NextBytes(data); + + var oneShot = QwpCrc32C.Compute(data); + + for (var split = 0; split <= data.Length; split += 17) + { + var first = QwpCrc32C.Compute(data.AsSpan(0, split)); + var chained = QwpCrc32C.Compute(data.AsSpan(split), first); + Assert.That(chained, Is.EqualTo(oneShot), $"split at {split}"); + } + } + + [Test] + public void Compute_SingleBitFlip_ChangesChecksum() + { + var rnd = new Random(0xFEED); + var data = new byte[256]; + rnd.NextBytes(data); + + var baseline = QwpCrc32C.Compute(data); + + for (var bytePos = 0; bytePos < data.Length; bytePos++) + { + for (var bit = 0; bit < 8; bit++) + { + data[bytePos] ^= (byte)(1 << bit); + Assert.That(QwpCrc32C.Compute(data), Is.Not.EqualTo(baseline), + $"flipping bit {bit} of byte {bytePos} did not change the checksum"); + data[bytePos] ^= (byte)(1 << bit); // restore + } + } + } + + [Test] + public void Compute_FrameEnvelopeShape_ChainEqualsOneShot() + { + var frame = Encoding.ASCII.GetBytes("hello qwp"); + Span envelope = stackalloc byte[4 + frame.Length]; + BinaryPrimitives.WriteInt32LittleEndian(envelope[..4], frame.Length); + frame.CopyTo(envelope[4..]); + + var crc = QwpCrc32C.Compute(envelope); + var crcChained = QwpCrc32C.Compute(frame, QwpCrc32C.Compute(envelope[..4])); + Assert.That(crc, Is.EqualTo(crcChained)); + Assert.That(crc, Is.Not.EqualTo(0u)); + } + + [Test] + public void Compute_OffsetIntoBuffer_MatchesSliceCompute() + { + var data = new byte[64]; + for (var i = 0; i < data.Length; i++) data[i] = (byte)(i * 7); + + for (var off = 0; off < 32; off++) + { + for (var len = 0; len < 32 && off + len <= data.Length; len++) + { + var sliced = QwpCrc32C.Compute(data.AsSpan(off, len)); + var copied = QwpCrc32C.Compute(data.AsSpan(off, len).ToArray()); + Assert.That(sliced, Is.EqualTo(copied), $"off={off} len={len}"); + } + } + } + + [Test] + public void Compute_VaryingLengths_NoBoundaryBugs() + { + // Specifically exercises the slice-by-8 loop boundary at lengths around 8, 16, 24, etc. + var rnd = new Random(0xBEEF); + for (var n = 0; n < 40; n++) + { + var data = new byte[n]; + rnd.NextBytes(data); + + // Compute with the slice-by-8 path and with byte-by-byte chaining; must match. + var fast = QwpCrc32C.Compute(data); + var bytewise = 0u; + for (var i = 0; i < n; i++) + { + bytewise = QwpCrc32C.Compute(data.AsSpan(i, 1), bytewise); + } + + Assert.That(fast, Is.EqualTo(bytewise), $"length {n}"); + } + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpCursorEngineDurableAckFuzzTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorEngineDurableAckFuzzTests.cs new file mode 100644 index 00000000..f3a83145 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorEngineDurableAckFuzzTests.cs @@ -0,0 +1,363 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Threading.Channels; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +/// +/// Mirrors java-questdb-client's CursorWebSocketSendLoopDurableAckFuzzTest. Generates +/// randomised OK + DURABLE_ACK sequences over a small table set and verifies the spec +/// invariant from sf-client.md §10.2: the engine's trim watermark equals the longest +/// contiguous prefix of pending wireSeqs whose every (table, seqTxn) is covered by the +/// accumulated durable watermarks (with empty entries trivially covered). +/// +[TestFixture] +public class QwpCursorEngineDurableAckFuzzTests +{ + private const int Iterations = 200; + private const int MaxFramesPerIteration = 48; + private static readonly string[] TablePool = { "trades", "orders", "fills", "positions" }; + + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qdb-da-fuzz-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_root, recursive: true); } + catch { } + } + + [Test] + public async Task DurableTrimInvariant_HoldsAcrossRandomSequences() + { + var seed = unchecked((int)(DateTime.UtcNow.Ticks ^ Environment.TickCount)); + var rnd = new Random(seed); + try + { + for (var iter = 0; iter < Iterations; iter++) + { + await RunOneIteration(rnd, iter); + } + } + catch (Exception ex) + { + throw new AssertionException($"fuzz failure with seed={seed}", ex); + } + } + + private async Task RunOneIteration(Random rnd, int iteration) + { + var frameCount = 1 + rnd.Next(MaxFramesPerIteration); + var frames = new FrameSpec[frameCount]; + var tableCursors = new Dictionary(StringComparer.Ordinal); + + for (var i = 0; i < frameCount; i++) + { + // Roughly 1-in-12 chance of an empty OK (matches the "materialized-view-only batch" + // edge case from the spec — trivially durable). + if (rnd.Next(12) == 0) + { + frames[i] = new FrameSpec(WireSeq: i, Array.Empty<(string, long)>()); + continue; + } + + var nTables = 1 + rnd.Next(TablePool.Length); + var picks = PickDistinct(rnd, TablePool, nTables); + var entries = new (string, long)[picks.Length]; + for (var k = 0; k < picks.Length; k++) + { + if (!tableCursors.TryGetValue(picks[k], out var cur)) cur = 0; + cur += 1 + rnd.Next(5); + tableCursors[picks[k]] = cur; + entries[k] = (picks[k], cur); + } + frames[i] = new FrameSpec(WireSeq: i, entries); + } + + // Build the script: OKs in strict wireSeq order, with DURABLE_ACK frames inserted at + // random positions. Each DURABLE_ACK carries a per-table watermark that lags the + // already-emitted OK frames by a random amount in [0, 3]. + var events = new List(); + var emittedPerTable = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < frameCount; i++) + { + events.Add(ResponseEvent.Ok(frames[i])); + foreach (var (name, seqTxn) in frames[i].Entries) + { + if (!emittedPerTable.TryGetValue(name, out var prev) || seqTxn > prev) + emittedPerTable[name] = seqTxn; + } + + // ~40% chance to inject a durable-ack at each tick. + if (rnd.Next(10) < 4 && emittedPerTable.Count > 0) + { + var watermarks = SampleWatermarks(rnd, emittedPerTable); + if (watermarks.Length > 0) events.Add(ResponseEvent.DurableAck(watermarks)); + } + } + + // Final durable-ack guarantees the full set is eventually covered so the trim must reach + // the tail; without it the oracle would correctly report a smaller AckedFsn. + if (emittedPerTable.Count > 0) + { + var final = new (string, long)[emittedPerTable.Count]; + var k = 0; + foreach (var kv in emittedPerTable) final[k++] = (kv.Key, kv.Value); + events.Add(ResponseEvent.DurableAck(final)); + } + + // Run the script against the engine and check the oracle. + var slotDir = Path.Combine(_root, $"iter-{iteration:D5}"); + var transport = new ScriptedTransport(); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromSeconds(5)); + using (var engine = new QwpCursorSendEngine( + slotLock, ring, () => transport, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.off, + durableAckMode: true)) + { + engine.Start(); + + Span payload = stackalloc byte[8]; + for (var i = 0; i < frameCount; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(payload, i); + engine.AppendBlocking(payload); + } + + await transport.WaitSent(frameCount); + + foreach (var ev in events) + { + transport.Push(ev.IsOk + ? OkAck(ev.Frame!.WireSeq, ev.Frame.Entries) + : DurableAck(ev.Watermarks!)); + } + + var expectedAcked = ComputeOracle(frames, events); + await WaitFor(() => engine.AckedFsn == expectedAcked, 3000, + $"iter={iteration} frames={frameCount} expected AckedFsn={expectedAcked} got {{actual}}"); + } + } + + private static long ComputeOracle(FrameSpec[] frames, List events) + { + var watermarks = new Dictionary(StringComparer.Ordinal); + foreach (var ev in events) + { + if (ev.IsOk) continue; + foreach (var (name, seqTxn) in ev.Watermarks!) + { + if (!watermarks.TryGetValue(name, out var prev) || seqTxn > prev) + watermarks[name] = seqTxn; + } + } + + var coveredPrefix = -1L; + foreach (var f in frames) + { + if (!CoversFrame(watermarks, f)) break; + coveredPrefix = f.WireSeq; + } + return coveredPrefix + 1; + } + + private static bool CoversFrame(Dictionary watermarks, FrameSpec f) + { + if (f.Entries.Length == 0) return true; + foreach (var (name, seqTxn) in f.Entries) + { + if (!watermarks.TryGetValue(name, out var w) || w < seqTxn) return false; + } + return true; + } + + private static string[] PickDistinct(Random rnd, string[] source, int count) + { + var copy = (string[])source.Clone(); + for (var i = copy.Length - 1; i > 0; i--) + { + var j = rnd.Next(i + 1); + (copy[i], copy[j]) = (copy[j], copy[i]); + } + var result = new string[count]; + Array.Copy(copy, result, count); + return result; + } + + private static (string, long)[] SampleWatermarks(Random rnd, Dictionary emittedPerTable) + { + // Report a random subset of tables, each at a random watermark in [1, emitted]. + var keys = new List(emittedPerTable.Keys); + var take = 1 + rnd.Next(keys.Count); + for (var i = keys.Count - 1; i > 0; i--) + { + var j = rnd.Next(i + 1); + (keys[i], keys[j]) = (keys[j], keys[i]); + } + var picks = new (string, long)[take]; + for (var k = 0; k < take; k++) + { + var emitted = emittedPerTable[keys[k]]; + // Pick a watermark anywhere from 1 up to emitted — durable-ack may lag. + var w = emitted == 0 ? 0 : 1 + rnd.NextInt64(emitted); + picks[k] = (keys[k], w); + } + return picks; + } + + private static byte[] OkAck(long seq, (string, long)[] entries) + { + var size = 11; + foreach (var (name, _) in entries) size += 2 + System.Text.Encoding.UTF8.GetByteCount(name) + 8; + + var buf = new byte[size]; + buf[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(1, 8), seq); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(9, 2), (ushort)entries.Length); + var pos = 11; + foreach (var (name, seqTxn) in entries) + { + var nameBytes = System.Text.Encoding.UTF8.GetBytes(name); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(buf, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(pos, 8), seqTxn); + pos += 8; + } + return buf; + } + + private static byte[] DurableAck((string, long)[] entries) + { + var size = 3; + foreach (var (name, _) in entries) size += 2 + System.Text.Encoding.UTF8.GetByteCount(name) + 8; + + var buf = new byte[size]; + buf[0] = (byte)QwpStatusCode.DurableAck; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(1, 2), (ushort)entries.Length); + var pos = 3; + foreach (var (name, seqTxn) in entries) + { + var nameBytes = System.Text.Encoding.UTF8.GetBytes(name); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(pos, 2), (ushort)nameBytes.Length); + pos += 2; + nameBytes.CopyTo(buf, pos); + pos += nameBytes.Length; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(pos, 8), seqTxn); + pos += 8; + } + return buf; + } + + private static async Task WaitFor(Func condition, int timeoutMs, string failureMessageTemplate) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(5); + } + Assert.Fail(failureMessageTemplate); + } + + private sealed record FrameSpec(long WireSeq, (string Name, long SeqTxn)[] Entries); + + private sealed class ResponseEvent + { + public bool IsOk { get; } + public FrameSpec? Frame { get; } + public (string, long)[]? Watermarks { get; } + + private ResponseEvent(bool isOk, FrameSpec? frame, (string, long)[]? watermarks) + { + IsOk = isOk; + Frame = frame; + Watermarks = watermarks; + } + + public static ResponseEvent Ok(FrameSpec frame) => new(true, frame, null); + public static ResponseEvent DurableAck((string, long)[] watermarks) => new(false, null, watermarks); + } + + private sealed class ScriptedTransport : IQwpCursorTransport + { + private readonly Channel _sent = Channel.CreateUnbounded(); + private readonly Channel _responses = Channel.CreateUnbounded(); + + public (string Host, int Port)? Endpoint { get; } = ("stub", 0); + + public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; + + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken ct) + { + await _sent.Writer.WriteAsync(data.ToArray(), ct).ConfigureAwait(false); + } + + public async Task ReceiveFrameAsync(Memory destination, CancellationToken ct) + { + var bytes = await _responses.Reader.ReadAsync(ct).ConfigureAwait(false); + bytes.CopyTo(destination.Span); + return bytes.Length; + } + + public Task CloseAsync(CancellationToken ct) => Task.CompletedTask; + + public void Dispose() => _responses.Writer.TryComplete(); + + public async Task WaitSent(int count, int timeoutMs = 5000) + { + using var cts = new CancellationTokenSource(timeoutMs); + for (var i = 0; i < count; i++) + { + await _sent.Reader.ReadAsync(cts.Token).ConfigureAwait(false); + } + } + + public void Push(byte[] frame) => _responses.Writer.TryWrite(frame); + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpCursorEngineDurableModeTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorEngineDurableModeTests.cs new file mode 100644 index 00000000..1e0fb7b5 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorEngineDurableModeTests.cs @@ -0,0 +1,354 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Threading.Channels; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpCursorEngineDurableModeTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qdb-da-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + try { Directory.Delete(_root, recursive: true); } + catch { } + } + + [Test] + public async Task Ok_InDurableMode_DoesNotAdvanceTrim() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 3); + await transport.WaitSent(3); + + transport.Push(OkAck(seq: 0, ("trades", 10))); + transport.Push(OkAck(seq: 1, ("trades", 20))); + transport.Push(OkAck(seq: 2, ("trades", 30))); + + await WaitFor(() => engine.TotalAcks == 3, "engine consumed all 3 OK frames"); + Assert.That(engine.AckedFsn, Is.EqualTo(0L), "OK frames alone must not advance trim in durable mode"); + } + + [Test] + public async Task DurableAck_CoversAllPending_AdvancesTrimToTail() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 3); + await transport.WaitSent(3); + + transport.Push(OkAck(seq: 0, ("trades", 10))); + transport.Push(OkAck(seq: 1, ("trades", 20))); + transport.Push(OkAck(seq: 2, ("trades", 30))); + transport.Push(DurableAck(("trades", 30))); + + await WaitFor(() => engine.AckedFsn == 3, "trim advanced to all 3 frames"); + } + + [Test] + public async Task DurableAck_PartialWatermark_AdvancesOnlyCoveredPrefix() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 3); + await transport.WaitSent(3); + + transport.Push(OkAck(seq: 0, ("trades", 10))); + transport.Push(OkAck(seq: 1, ("trades", 20))); + transport.Push(OkAck(seq: 2, ("trades", 30))); + // Watermark only covers wireSeq 0 and 1, not 2. + transport.Push(DurableAck(("trades", 20))); + + await WaitFor(() => engine.AckedFsn == 2, "trim advanced to covered prefix only"); + Assert.That(engine.AckedFsn, Is.EqualTo(2L)); + } + + [Test] + public async Task DurableAck_CatchUpWatermark_DrainsRemainingPrefix() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 4); + await transport.WaitSent(4); + + transport.Push(OkAck(seq: 0, ("trades", 10))); + transport.Push(OkAck(seq: 1, ("trades", 20))); + transport.Push(OkAck(seq: 2, ("trades", 30))); + transport.Push(OkAck(seq: 3, ("trades", 40))); + transport.Push(DurableAck(("trades", 20))); + await WaitFor(() => engine.AckedFsn == 2, "covered prefix [0, 1]"); + + transport.Push(DurableAck(("trades", 40))); + await WaitFor(() => engine.AckedFsn == 4, "tail covered, full drain"); + } + + [Test] + public async Task EmptyOk_TriviallyDurableAtHead_PopsImmediately() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 1); + await transport.WaitSent(1); + + // tableCount = 0: no per-table commit to wait for; trims immediately on enqueue. + transport.Push(OkAck(seq: 0)); + + await WaitFor(() => engine.AckedFsn == 1, "trivially durable empty OK pops at head"); + } + + [Test] + public async Task EmptyOk_BehindUncoveredEntry_WaitsThenPops() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 2); + await transport.WaitSent(2); + + transport.Push(OkAck(seq: 0, ("trades", 10))); // needs watermark + transport.Push(OkAck(seq: 1)); // trivially durable + await WaitFor(() => engine.TotalAcks == 2, "engine consumed both OK frames"); + Assert.That(engine.AckedFsn, Is.EqualTo(0L), "head not yet covered, FIFO stalls behind it"); + + transport.Push(DurableAck(("trades", 10))); + await WaitFor(() => engine.AckedFsn == 2, "head covered ⇒ both entries pop in order"); + } + + [Test] + public async Task MultiTable_AllTablesMustReachWatermark() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 1); + await transport.WaitSent(1); + + transport.Push(DurableAck(("trades", 100))); + transport.Push(OkAck(seq: 0, ("trades", 100), ("fills", 200))); + // DurableAck queued before the OK on one FIFO channel: consuming the OK proves it landed too. + await WaitFor(() => engine.TotalAcks == 1, "engine consumed the OK frame"); + Assert.That(engine.AckedFsn, Is.EqualTo(0L), "fills not yet covered"); + + transport.Push(DurableAck(("fills", 200))); + await WaitFor(() => engine.AckedFsn == 1, "both tables now covered"); + } + + [Test] + public async Task DurableWatermark_MaxMerge_ToleratesReordering() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: true); + engine.Start(); + + AppendFrames(engine, 2); + await transport.WaitSent(2); + + transport.Push(OkAck(seq: 0, ("trades", 100))); + transport.Push(DurableAck(("trades", 100))); + await WaitFor(() => engine.AckedFsn == 1); + + // Stale ack queued before the second frame's acks; reaching AckedFsn==2 proves it was + // consumed without regressing state. + transport.Push(DurableAck(("trades", 50))); + transport.Push(OkAck(seq: 1, ("trades", 150))); + transport.Push(DurableAck(("trades", 150))); + await WaitFor(() => engine.AckedFsn == 2, "second frame drains; stale ack did not regress state"); + Assert.That(engine.AckedFsn, Is.EqualTo(2L)); + } + + [Test] + public async Task NonDurableMode_OkAdvancesTrim_LegacyPath() + { + var transport = new ScriptedTransport(); + using var engine = NewEngine(out _, transport, durableAckMode: false); + engine.Start(); + + AppendFrames(engine, 2); + await transport.WaitSent(2); + + transport.Push(OkAck(seq: 0)); + transport.Push(OkAck(seq: 1)); + + await WaitFor(() => engine.AckedFsn == 2, "non-durable mode trims on OK directly"); + } + + private QwpCursorSendEngine NewEngine( + out string slotDir, + ScriptedTransport transport, + bool durableAckMode) + { + slotDir = Path.Combine(_root, "engine-" + Guid.NewGuid().ToString("N")); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromSeconds(5)); + return new QwpCursorSendEngine( + slotLock, ring, () => transport, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.off, + durableAckMode: durableAckMode); + } + + private static void AppendFrames(QwpCursorSendEngine engine, int count) + { + Span payload = stackalloc byte[8]; + for (var i = 0; i < count; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(payload, i); + engine.AppendBlocking(payload); + } + } + + private static byte[] OkAck(long seq, params (string Name, long SeqTxn)[] entries) + { + var size = 11; + foreach (var e in entries) size += 2 + System.Text.Encoding.UTF8.GetByteCount(e.Name) + 8; + + var buf = new byte[size]; + buf[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(1, 8), seq); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(9, 2), (ushort)entries.Length); + + var pos = 11; + foreach (var e in entries) + { + var name = System.Text.Encoding.UTF8.GetBytes(e.Name); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(pos, 2), (ushort)name.Length); + pos += 2; + name.CopyTo(buf, pos); + pos += name.Length; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(pos, 8), e.SeqTxn); + pos += 8; + } + return buf; + } + + private static byte[] DurableAck(params (string Name, long SeqTxn)[] entries) + { + var size = 3; + foreach (var e in entries) size += 2 + System.Text.Encoding.UTF8.GetByteCount(e.Name) + 8; + + var buf = new byte[size]; + buf[0] = (byte)QwpStatusCode.DurableAck; + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(1, 2), (ushort)entries.Length); + + var pos = 3; + foreach (var e in entries) + { + var name = System.Text.Encoding.UTF8.GetBytes(e.Name); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(pos, 2), (ushort)name.Length); + pos += 2; + name.CopyTo(buf, pos); + pos += name.Length; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(pos, 8), e.SeqTxn); + pos += 8; + } + return buf; + } + + private static async Task WaitFor(Func condition, string? message = null, int timeoutMs = 2000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(5); + } + Assert.Fail(message ?? "condition never held"); + } + + // Transport that captures every frame the engine sends and lets the test push arbitrary + // response bytes to the receive pump in any order (including durable-ack frames interleaved + // between OKs). + private sealed class ScriptedTransport : IQwpCursorTransport + { + private readonly Channel _sent = Channel.CreateUnbounded(); + private readonly Channel _responses = Channel.CreateUnbounded(); + + public (string Host, int Port)? Endpoint { get; } = ("stub", 0); + + public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; + + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken ct) + { + await _sent.Writer.WriteAsync(data.ToArray(), ct).ConfigureAwait(false); + } + + public async Task ReceiveFrameAsync(Memory destination, CancellationToken ct) + { + var bytes = await _responses.Reader.ReadAsync(ct).ConfigureAwait(false); + bytes.CopyTo(destination.Span); + return bytes.Length; + } + + public Task CloseAsync(CancellationToken ct) => Task.CompletedTask; + + public void Dispose() => _responses.Writer.TryComplete(); + + public async Task WaitSent(int count, int timeoutMs = 2000) + { + using var cts = new CancellationTokenSource(timeoutMs); + for (var i = 0; i < count; i++) + { + await _sent.Reader.ReadAsync(cts.Token).ConfigureAwait(false); + } + } + + public void Push(byte[] frame) => _responses.Writer.TryWrite(frame); + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineMultiHostTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineMultiHostTests.cs new file mode 100644 index 00000000..f4e2168c --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineMultiHostTests.cs @@ -0,0 +1,358 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpCursorSendEngineMultiHostTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-mh-engine-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + try { Directory.Delete(_root, recursive: true); } + catch { /* mmap on Windows can hold the file briefly */ } + } + } + + [Test] + public async Task RotatesPastReplicaToAccepting_FrameAcked() + { + var hosts = new[] { "replica.example:9000", "primary.example:9000" }; + var tracker = new QwpHostHealthTracker(hosts); + var stubs = new List(); + + Func factory = () => + { + var idx = tracker.PickNext(); + if (idx < 0) + { + tracker.BeginRound(forgetClassifications: true); + idx = tracker.PickNext(); + } + + var stub = idx == 0 + ? new MhStubTransport(rejectWithRole: QwpConstants.RoleReplicaName, hosts[idx]) + : new MhStubTransport(rejectWithRole: null, hosts[idx]); + stubs.Add(stub); + return new QwpTrackedCursorTransport(stub, tracker, idx); + }; + + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromSeconds(5)); + using var engine = new QwpCursorSendEngine( + slotLock, ring, factory, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.async, + skipBackoffPredicate: () => !tracker.IsRoundExhausted); + + engine.Start(); + engine.AppendBlocking(new byte[] { 7 }); + + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(engine.AckedFsn, Is.EqualTo(1L)); + Assert.That(tracker.GetState(0), Is.EqualTo(QwpHostState.TopologyReject)); + Assert.That(tracker.GetState(1), Is.EqualTo(QwpHostState.Healthy)); + + // Replica stub never sent anything; primary stub received the frame. + var primaryStubs = stubs.FindAll(s => s.HostId == hosts[1]); + Assert.That(primaryStubs, Has.Count.GreaterThanOrEqualTo(1)); + var sentBytes = 0; + foreach (var s in primaryStubs) sentBytes += s.Sent.Count; + Assert.That(sentBytes, Is.EqualTo(1)); + } + + [Test] + public async Task PrimaryCatchupReject_ClassifiedTransient_ContinuesToNext() + { + var hosts = new[] { "catchup.example:9000", "primary.example:9000" }; + var tracker = new QwpHostHealthTracker(hosts); + + Func factory = () => + { + var idx = tracker.PickNext(); + if (idx < 0) + { + tracker.BeginRound(forgetClassifications: true); + idx = tracker.PickNext(); + } + + var stub = idx == 0 + ? new MhStubTransport(rejectWithRole: QwpConstants.RolePrimaryCatchupName, hosts[idx]) + : new MhStubTransport(rejectWithRole: null, hosts[idx]); + return new QwpTrackedCursorTransport(stub, tracker, idx); + }; + + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromSeconds(5)); + using var engine = new QwpCursorSendEngine( + slotLock, ring, factory, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.async, + skipBackoffPredicate: () => !tracker.IsRoundExhausted); + + engine.Start(); + engine.AppendBlocking(new byte[] { 9 }); + + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(engine.AckedFsn, Is.EqualTo(1L)); + Assert.That(tracker.GetState(0), Is.EqualTo(QwpHostState.TransientReject)); + Assert.That(tracker.GetState(1), Is.EqualTo(QwpHostState.Healthy)); + } + + [Test] + public async Task FailedOver_FiresWhenSuccessHostDiffersFromPrevious() + { + var hosts = new[] { "h1:9000", "h2:9000" }; + var tracker = new QwpHostHealthTracker(hosts); + var rejectFirst = true; + var stubsByIdx = new MhStubTransport?[hosts.Length]; + + Func factory = () => + { + var idx = tracker.PickNext(); + if (idx < 0) + { + tracker.BeginRound(forgetClassifications: true); + idx = tracker.PickNext(); + } + var rejectThis = idx == 0 && rejectFirst; + var stub = new MhStubTransport(rejectWithRole: rejectThis ? QwpConstants.RoleReplicaName : null, hosts[idx]); + stubsByIdx[idx] = stub; + return new QwpTrackedCursorTransport(stub, tracker, idx); + }; + + var events = new List(); + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromSeconds(5)); + using var engine = new QwpCursorSendEngine( + slotLock, ring, factory, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.async, + skipBackoffPredicate: () => !tracker.IsRoundExhausted, + connectionEventSink: evt => { lock (events) events.Add(evt); }); + + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + QuestDB.Senders.SenderConnectionEvent[] snapshot; + lock (events) snapshot = events.ToArray(); + Assert.That(snapshot, Has.Some.Matches( + e => e.Kind == QuestDB.Senders.SenderConnectionEventKind.EndpointAttemptFailed && e.Host == "h1"), + "must observe ENDPOINT_ATTEMPT_FAILED for the rejected first host"); + Assert.That(snapshot, Has.Some.Matches( + e => e.Kind == QuestDB.Senders.SenderConnectionEventKind.Connected && e.Host == "h2"), + "first success on a different host than the original pick is still CONNECTED (no prior live endpoint)"); + } + + [Test] + public async Task AllEndpointsUnreachable_FiresPerSweepWhenAllHostsRejected() + { + var hosts = new[] { "r1:9000", "r2:9000" }; + var tracker = new QwpHostHealthTracker(hosts); + + Func factory = () => + { + var idx = tracker.PickNext(); + if (idx < 0) + { + tracker.BeginRound(forgetClassifications: true); + idx = tracker.PickNext(); + } + var stub = new MhStubTransport(rejectWithRole: QwpConstants.RoleReplicaName, hosts[idx]); + return new QwpTrackedCursorTransport(stub, tracker, idx); + }; + + var events = new List(); + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(5), + TimeSpan.FromMilliseconds(20), + TimeSpan.FromMilliseconds(400)); + using var engine = new QwpCursorSendEngine( + slotLock, ring, factory, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.async, + skipBackoffPredicate: () => !tracker.IsRoundExhausted, + connectionEventSink: evt => { lock (events) events.Add(evt); }); + + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + Assert.ThrowsAsync(async () => await engine.FlushAsync(TimeSpan.FromSeconds(2))); + + QuestDB.Senders.SenderConnectionEvent[] snapshot; + lock (events) snapshot = events.ToArray(); + var allUnreachableCount = snapshot.Count(e => + e.Kind == QuestDB.Senders.SenderConnectionEventKind.AllEndpointsUnreachable); + Assert.That(allUnreachableCount, Is.GreaterThanOrEqualTo(1), + "every failed sweep must fire ALL_ENDPOINTS_UNREACHABLE at least once"); + Assert.That(snapshot, Has.Some.Matches( + e => e.Kind == QuestDB.Senders.SenderConnectionEventKind.ReconnectBudgetExhausted), + "budget exhaustion must surface a terminal event"); + } + + [Test] + public async Task AllHostsReplica_ExhaustsOutageBudgetThenTerminal() + { + var hosts = new[] { "r1:9000", "r2:9000" }; + var tracker = new QwpHostHealthTracker(hosts); + var attempts = 0; + + Func factory = () => + { + Interlocked.Increment(ref attempts); + var idx = tracker.PickNext(); + if (idx < 0) + { + tracker.BeginRound(forgetClassifications: true); + idx = tracker.PickNext(); + } + var stub = new MhStubTransport(rejectWithRole: QwpConstants.RoleReplicaName, hosts[idx]); + return new QwpTrackedCursorTransport(stub, tracker, idx); + }; + + var slotDir = Path.Combine(_root, "slot"); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + // Tight budget; role-rejects must consume the wall-clock outage budget so a permanent + // REPLICA topology eventually surfaces as terminal rather than blocking forever. + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(5), + TimeSpan.FromMilliseconds(20), + TimeSpan.FromMilliseconds(400)); + using var engine = new QwpCursorSendEngine( + slotLock, ring, factory, policy, + appendDeadline: TimeSpan.FromSeconds(5), + initialConnectMode: InitialConnectMode.async, + skipBackoffPredicate: () => !tracker.IsRoundExhausted); + + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + + Assert.ThrowsAsync(async () => + await engine.FlushAsync(TimeSpan.FromSeconds(2))); + + Assert.That(attempts, Is.GreaterThanOrEqualTo(hosts.Length), + "must rotate through every host at least once before giving up"); + Assert.That(engine.IsTerminallyFailed, Is.True, + "role-rejects consume the outage budget; a permanent REPLICA topology must terminate"); + } + + private sealed class MhStubTransport : IQwpCursorTransport + { + public string HostId { get; } + public List Sent { get; } = new(); + public (string Host, int Port)? Endpoint { get; } + + private readonly string? _rejectWithRole; + private readonly Channel _acks = Channel.CreateUnbounded(); + private int _autoSeq; + + public MhStubTransport(string? rejectWithRole, string hostId) + { + _rejectWithRole = rejectWithRole; + HostId = hostId; + var colon = hostId.IndexOf(':'); + Endpoint = colon < 0 + ? (hostId, 9000) + : (hostId.Substring(0, colon), int.Parse(hostId.Substring(colon + 1))); + } + + public Task ConnectAsync(CancellationToken cancellationToken) + { + if (_rejectWithRole is { } role) + { + throw new QwpIngressRoleRejectedException(role, new Uri($"ws://{HostId}/write/v4")); + } + return Task.CompletedTask; + } + + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + Sent.Add(data.ToArray()); + var ack = new byte[11]; + ack[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(ack.AsSpan(1, 8), Interlocked.Increment(ref _autoSeq) - 1); + BinaryPrimitives.WriteUInt16LittleEndian(ack.AsSpan(9, 2), 0); + await _acks.Writer.WriteAsync(ack, cancellationToken).ConfigureAwait(false); + } + + public async Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken) + { + var ack = await _acks.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + ack.CopyTo(destination.Span); + return ack.Length; + } + + public Task CloseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() => _acks.Writer.TryComplete(); + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineTests.cs new file mode 100644 index 00000000..56d29947 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineTests.cs @@ -0,0 +1,1017 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Threading.Channels; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpCursorSendEngineTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-engine-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + try { Directory.Delete(_root, recursive: true); } + catch { /* mmap on Windows can hold the file briefly */ } + } + } + + [Test] + public void Constructor_NullArgs_Throws() + { + var slotLock = QwpSlotLock.Acquire(Path.Combine(_root, "s")); + try + { + var ring = QwpSegmentRing.Open(slotLock.SlotDirectory, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50), TimeSpan.FromSeconds(1)); + + // slotLock=null is permitted (drain mode — pool owns the lock externally). + Assert.Throws(() => + new QwpCursorSendEngine(slotLock, null!, () => null!, policy, TimeSpan.FromSeconds(5), InitialConnectMode.off)); + Assert.Throws(() => + new QwpCursorSendEngine(slotLock, ring, null!, policy, TimeSpan.FromSeconds(5), InitialConnectMode.off)); + Assert.Throws(() => + new QwpCursorSendEngine(slotLock, ring, () => null!, null!, TimeSpan.FromSeconds(5), InitialConnectMode.off)); + Assert.Throws(() => + new QwpCursorSendEngine(slotLock, ring, () => null!, policy, TimeSpan.Zero, InitialConnectMode.off)); + + ring.Dispose(); + } + finally + { + slotLock.Dispose(); + } + } + + [Test] + public async Task AppendAndDrain_HappyPath_AllFramesAcked() + { + var stubs = new List(); + using var engine = NewEngine(out var slotDir, factory: () => + { + var s = new StubTransport(); + stubs.Add(s); + return s; + }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + engine.AppendBlocking(new byte[] { 3 }); + + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(engine.AckedFsn, Is.EqualTo(3L)); + Assert.That(engine.NextFsn, Is.EqualTo(3L)); + Assert.That(stubs, Has.Count.EqualTo(1)); + Assert.That(stubs[0].Sent.Select(b => b[0]).ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); + } + + [Test] + public async Task AppendAsync_HappyPath_FrameLandsOnWire() + { + var stubs = new List(); + using var engine = NewEngine(out _, factory: () => + { + var s = new StubTransport(); + stubs.Add(s); + return s; + }); + engine.Start(); + + await engine.AppendAsync(new byte[] { 1, 2, 3 }); + await engine.AppendAsync(new byte[] { 4 }); + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(engine.AckedFsn, Is.EqualTo(2L)); + Assert.That(stubs[0].Sent.Select(b => b[0]).ToArray(), Is.EqualTo(new byte[] { 1, 4 })); + } + + [Test] + public async Task AppendAsync_DeadlineExpired_Throws() + { + using var sendGate = new SemaphoreSlim(0, int.MaxValue); + using var engine = NewEngine(out _, + segmentCapacity: QwpMmapSegment.HeaderSize + 64, + maxTotalBytes: QwpMmapSegment.HeaderSize + 64, + appendDeadline: TimeSpan.FromMilliseconds(100), + factory: () => new StubTransport + { + OnSendGate = ct => sendGate.WaitAsync(ct) + }); + engine.Start(); + + await engine.AppendAsync(new byte[24]); + await engine.AppendAsync(new byte[24]); + + var ex = Assert.ThrowsAsync(async () => await engine.AppendAsync(new byte[24])); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ServerFlushError)); + Assert.That(ex.Message, Does.Contain("sf_append_deadline")); + + sendGate.Release(8); + } + + [Test] + public async Task AppendAsync_DoesNotBlockCallingThread() + { + using var sendGate = new SemaphoreSlim(0, int.MaxValue); + using var engine = NewEngine(out _, + segmentCapacity: QwpMmapSegment.HeaderSize + 64, + maxTotalBytes: QwpMmapSegment.HeaderSize + 64, + appendDeadline: TimeSpan.FromSeconds(10), + factory: () => new StubTransport + { + OnSendGate = ct => sendGate.WaitAsync(ct) + }); + engine.Start(); + + await engine.AppendAsync(new byte[24]); + await engine.AppendAsync(new byte[24]); + + var pending = engine.AppendAsync(new byte[24]); + Assert.That(pending.IsCompleted, Is.False, "third append must be pending while ring is full"); + + sendGate.Release(2); + await pending.AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + sendGate.Release(8); + } + + [Test] + public async Task FullDrain_OnDispose_UnlinksSegmentFiles() + { + var slotDir = Path.Combine(_root, "drain-cleanup"); + + { + using var engine = NewEngine(out _, slotDirectoryOverride: slotDir); + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + Assert.That(engine.AckedFsn, Is.EqualTo(engine.NextFsn)); + } + + var residual = Directory.GetFiles(slotDir, "sf-*.sfa"); + Assert.That(residual, Is.Empty, "fully-drained slot must have no segment files left after dispose"); + } + + [Test] + public async Task PartialDrain_OnDispose_PreservesSegmentFiles() + { + var slotDir = Path.Combine(_root, "drain-partial"); + var sendGate = new SemaphoreSlim(0, int.MaxValue); + + using (var engine = NewEngine(out _, + slotDirectoryOverride: slotDir, + factory: () => new StubTransport { OnSendGate = ct => sendGate.WaitAsync(ct) })) + { + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + await Task.Delay(100); + } + + var residual = Directory.GetFiles(slotDir, "sf-*.sfa"); + Assert.That(residual, Is.Not.Empty, "un-acked data must survive engine dispose"); + sendGate.Release(8); + } + + [Test] + public async Task Recovery_ReopenSlotWithExistingSegments_ReplaysFromOldestFsn() + { + var slotDir = Path.Combine(_root, "recovery"); + + var sendGate = new SemaphoreSlim(0, int.MaxValue); + using (var first = NewEngine(out _, + slotDirectoryOverride: slotDir, + factory: () => new StubTransport { OnSendGate = ct => sendGate.WaitAsync(ct) })) + { + first.Start(); + first.AppendBlocking(new byte[] { 10 }); + first.AppendBlocking(new byte[] { 20 }); + first.AppendBlocking(new byte[] { 30 }); + await Task.Delay(100); + } + sendGate.Release(int.MaxValue / 2); + + var stubs = new List(); + using (var second = NewEngine(out _, + slotDirectoryOverride: slotDir, + factory: () => { var s = new StubTransport(); stubs.Add(s); return s; })) + { + second.Start(); + await second.FlushAsync(TimeSpan.FromSeconds(5)); + Assert.That(second.AckedFsn, Is.EqualTo(3L), "all 3 recovered envelopes must be acked"); + } + + Assert.That(Directory.GetFiles(slotDir, "sf-*.sfa"), Is.Empty); + + var sentBytes = stubs.SelectMany(s => s.Sent).Select(f => f[^1]).ToHashSet(); + Assert.That(sentBytes.IsSupersetOf(new byte[] { 10, 20, 30 }), "recovered payloads must reach the wire"); + } + + [Test] + public async Task AwaitAckedFsnAsync_TargetReached_ReturnsTrue() + { + using var engine = NewEngine(out _, factory: () => new StubTransport()); + engine.Start(); + + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + + var ok = await engine.AwaitAckedFsnAsync(targetFsn: 2L, TimeSpan.FromSeconds(5)); + Assert.That(ok, Is.True); + Assert.That(engine.AckedFsn, Is.GreaterThanOrEqualTo(2L)); + } + + [Test] + public async Task AwaitAckedFsnAsync_TargetUnreached_ReturnsFalseOnTimeout() + { + using var sendGate = new SemaphoreSlim(0, int.MaxValue); + using var engine = NewEngine(out _, + factory: () => new StubTransport { OnSendGate = ct => sendGate.WaitAsync(ct) }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 1 }); + + var ok = await engine.AwaitAckedFsnAsync(targetFsn: 1L, TimeSpan.FromMilliseconds(200)); + Assert.That(ok, Is.False); + + sendGate.Release(8); + } + + [Test] + public void AwaitAckedFsnAsync_Terminal_Throws() + { + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnConnect = _ => throw new IngressError(ErrorCode.AuthError, "401") + }); + engine.Start(); + AssertEventually(() => engine.IsTerminallyFailed, "engine never marked terminal"); + + var ex = Assert.ThrowsAsync(async () => + await engine.AwaitAckedFsnAsync(targetFsn: 1L, TimeSpan.FromSeconds(1))); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.AuthError)); + } + + [Test] + public void AuthError_OnConnect_MarksTerminalAndThrowsOnAppend() + { + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnConnect = _ => throw new IngressError(ErrorCode.AuthError, "401 unauthorized") + }); + engine.Start(); + + AssertEventually(() => engine.IsTerminallyFailed, "engine never observed AuthError"); + var ex = Assert.Catch(() => engine.AppendBlocking(new byte[] { 1 })); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.AuthError)); + } + + [Test] + public async Task ReceivePump_ServerLiesAboutAckSequence_ClampsToHighestStaged() + { + using var engine = NewEngine(out _, factory: () => + new StubTransport + { + OnSendAsync = _ => Task.FromResult(OkResponse(sequence: 1_000_000)), + }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + engine.AppendBlocking(new byte[] { 3 }); + + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(engine.AckedFsn, Is.EqualTo(3L)); + } + + [Test] + public void InitialConnectFailure_NoRetry_Terminal() + { + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnConnect = _ => throw new IngressError(ErrorCode.SocketError, "connection refused") + }, initialConnectMode: InitialConnectMode.off); + engine.Start(); + + AssertEventually(() => engine.IsTerminallyFailed, "engine never marked terminal after initial connect failure"); + Assert.That(engine.TerminalError, Is.InstanceOf()); + } + + [Test] + public async Task InitialConnectFailure_WithRetry_RecoversAndDrains() + { + var attempts = 0; + using var engine = NewEngine(out _, factory: () => + { + attempts++; + return new StubTransport + { + OnConnect = _ => attempts < 3 + ? throw new IngressError(ErrorCode.SocketError, $"attempt {attempts} refused") + : Task.CompletedTask + }; + }, initialConnectMode: InitialConnectMode.on); + engine.Start(); + + engine.AppendBlocking(new byte[] { 7 }); + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(attempts, Is.GreaterThanOrEqualTo(3)); + Assert.That(engine.AckedFsn, Is.EqualTo(1L)); + } + + [Test] + public async Task WireFailureMidStream_ReconnectsAndReplays() + { + var stubs = new List(); + var connectCount = 0; + using var engine = NewEngine(out _, factory: () => + { + connectCount++; + var idx = connectCount; + var s = new StubTransport(); + if (idx == 1) + { + // First stub: ack the first frame, then throw on the second send. + var sent = 0; + s.OnSend = _ => + { + sent++; + if (sent == 1) return OkResponse(0); + throw new IngressError(ErrorCode.SocketError, "broken pipe"); + }; + } + + stubs.Add(s); + return s; + }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 0 }); + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + + Assert.That(connectCount, Is.GreaterThanOrEqualTo(2), "reconnect did not occur"); + // Frame at FSN 0 was acked on the first connection; FSN 1 + 2 replayed on the second. + Assert.That(engine.AckedFsn, Is.EqualTo(3L)); + + var perTransport = stubs + .Select(s => (IReadOnlyList)s.Sent.Select(b => (int)b[0]).ToList()) + .ToList(); + AssertAtLeastOnceDelivery(perTransport, expectedCount: 3); + } + + [Test] + public void ReconnectBudgetExhausted_Terminal() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(20), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromMilliseconds(150)); + using var engine = NewEngine(out _, + factory: () => new StubTransport + { + OnConnect = _ => throw new IngressError(ErrorCode.SocketError, "always refused") + }, + policy: policy, + initialConnectMode: InitialConnectMode.on); + engine.Start(); + + AssertEventually(() => engine.IsTerminallyFailed, "budget never exhausted", timeoutMs: 2000); + Assert.That(engine.TerminalError, Is.InstanceOf()); + } + + [Test] + public void ErrorHandler_FiresOnInitialConnectExhaustion() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(20), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromMilliseconds(150)); + SenderError? captured = null; + var fired = new ManualResetEventSlim(); + using var engine = NewEngine(out _, + factory: () => new StubTransport + { + OnConnect = _ => throw new IngressError(ErrorCode.SocketError, "always refused") + }, + policy: policy, + initialConnectMode: InitialConnectMode.async, + errorHandler: e => { captured = e; fired.Set(); }); + engine.Start(); + + Assert.That(fired.Wait(TimeSpan.FromSeconds(2)), Is.True, "error_handler never fired"); + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.IsInitialConnect, Is.True); + Assert.That(captured.Exception, Is.InstanceOf()); + } + + [Test] + public void ErrorHandler_FiresOnceForServerReject_AfterConnect() + { + var fireCount = 0; + var fired = new ManualResetEventSlim(); + SenderError? captured = null; + using var engine = NewEngine(out _, + factory: () => new StubTransport + { + OnSend = _ => ErrorResponse(QwpStatusCode.InternalError, sequence: 0, "boom") + }, + errorHandler: e => + { + Interlocked.Increment(ref fireCount); + captured = e; + fired.Set(); + }); + engine.Start(); + engine.AppendBlocking(new byte[] { 9 }); + + Assert.That(fired.Wait(TimeSpan.FromSeconds(2)), Is.True, "error_handler never fired"); + AssertEventually(() => engine.IsTerminallyFailed, "engine should mark terminal"); + Thread.Sleep(100); + Assert.That(fireCount, Is.EqualTo(1)); + Assert.That(captured!.Category, Is.EqualTo(SenderErrorCategory.InternalError)); + Assert.That(captured.AppliedPolicy, Is.EqualTo(SenderErrorPolicy.Halt)); + Assert.That(captured.IsInitialConnect, Is.False); + } + + [Test] + public void ErrorHandler_ThrowsAreSwallowed() + { + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSend = _ => ErrorResponse(QwpStatusCode.InternalError, sequence: 0, "boom") + }, errorHandler: _ => throw new InvalidOperationException("user bug")); + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + + AssertEventually(() => engine.IsTerminallyFailed, "engine should mark terminal"); + Assert.That(engine.TerminalError, Is.InstanceOf()); + } + + [Test] + public void DropAndContinue_SchemaMismatch_AdvancesAckAndKeepsRunning() + { + var sendCount = 0; + var fired = new ManualResetEventSlim(); + SenderError? captured = null; + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSend = _ => + { + var seq = sendCount++; + return seq == 0 + ? ErrorResponse(QwpStatusCode.SchemaMismatch, sequence: 0, "schema mismatch") + : OkResponse(seq); + } + }, errorHandler: e => { captured = e; fired.Set(); }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + + Assert.That(fired.Wait(TimeSpan.FromSeconds(2)), Is.True, "error_handler never fired"); + Assert.That(captured!.Category, Is.EqualTo(SenderErrorCategory.SchemaMismatch)); + Assert.That(captured.AppliedPolicy, Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + AssertEventually(() => engine.AckedFsn >= 2L, "ack watermark should advance past dropped + ok'd batches"); + Assert.That(engine.IsTerminallyFailed, Is.False, "drop-and-continue must not mark engine terminal"); + } + + [Test] + public void PolicyResolver_OverrideMakesDropableHalt() + { + SenderError? captured = null; + var fired = new ManualResetEventSlim(); + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSend = _ => ErrorResponse(QwpStatusCode.SchemaMismatch, sequence: 0, "schema mismatch") + }, errorHandler: e => { captured = e; fired.Set(); }, + policyResolver: _ => SenderErrorPolicy.Halt); + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + + Assert.That(fired.Wait(TimeSpan.FromSeconds(2)), Is.True); + Assert.That(captured!.AppliedPolicy, Is.EqualTo(SenderErrorPolicy.Halt)); + AssertEventually(() => engine.IsTerminallyFailed, "halt override must make engine terminal"); + Assert.That(engine.TerminalError, Is.InstanceOf()); + } + + [Test] + public void PolicyResolver_CannotOverrideUnknownToDrop() + { + // Resolver returns DropAndContinue, but UNKNOWN must be forced HALT regardless. + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSend = _ => ErrorResponse((QwpStatusCode)0xFE, sequence: 0, "what") + }, errorHandler: _ => { }, + policyResolver: _ => SenderErrorPolicy.DropAndContinue); + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + + AssertEventually(() => engine.IsTerminallyFailed, "Unknown category must always halt"); + } + + [Test] + public void Senderror_Fields_PopulatedFromResponse() + { + SenderError? captured = null; + var fired = new ManualResetEventSlim(); + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSend = _ => ErrorResponse(QwpStatusCode.InternalError, sequence: 7, "boom") + }, errorHandler: e => { captured = e; fired.Set(); }); + engine.Start(); + engine.AppendBlocking(new byte[] { 1 }); + + Assert.That(fired.Wait(TimeSpan.FromSeconds(2)), Is.True); + Assert.That(captured!.ServerStatusByte, Is.EqualTo((int)QwpStatusCode.InternalError)); + Assert.That(captured.ServerMessage, Is.EqualTo("boom")); + Assert.That(captured.MessageSequence, Is.EqualTo(7)); + Assert.That(captured.FromFsn, Is.EqualTo(0L)); + Assert.That(captured.ToFsn, Is.EqualTo(0L)); + } + + [Test] + public void ErrorInbox_Overflow_BumpsDroppedCounter() + { + var release = new ManualResetEventSlim(); + var seen = 0; + var sendCount = 0; + var slotDir = Path.Combine(_root, "sender-" + Guid.NewGuid().ToString("N")); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: 4096); + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(40), TimeSpan.FromSeconds(2)); + using var dispatcher = new QwpSenderErrorDispatcher(_ => + { + Interlocked.Increment(ref seen); + release.Wait(); + }, capacity: 1); + using var engine = new QwpCursorSendEngine( + slotLock, ring, + () => new StubTransport + { + OnSend = _ => ErrorResponse(QwpStatusCode.SchemaMismatch, sequence: sendCount++, "drop") + }, + policy, + TimeSpan.FromSeconds(5), + InitialConnectMode.off, + errorDispatcher: dispatcher); + engine.Start(); + for (var i = 0; i < 64; i++) + { + engine.AppendBlocking(new byte[] { (byte)i }); + } + AssertEventually(() => Volatile.Read(ref seen) >= 1, "first notification never delivered"); + AssertEventually(() => dispatcher.DroppedNotifications > 0, + "no dropped notifications recorded despite the parked handler"); + release.Set(); + } + + [Test] + public void ServerErrorResponse_Terminal_OnHaltCategory() + { + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSend = _ => ErrorResponse(QwpStatusCode.InternalError, sequence: 0, "boom") + }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 9 }); + AssertEventually(() => engine.IsTerminallyFailed, "engine should mark terminal on Halt-policy reject"); + Assert.That(engine.TerminalError, Is.InstanceOf()); + var lse = (LineSenderServerException)engine.TerminalError!; + Assert.That(lse.Error.Category, Is.EqualTo(SenderErrorCategory.InternalError)); + Assert.That(lse.Error.AppliedPolicy, Is.EqualTo(SenderErrorPolicy.Halt)); + } + + [Test] + public async Task FlushAsync_TimesOut_WhenWireDoesNotDrain() + { + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var engine = NewEngine(out _, factory: () => new StubTransport + { + OnSendAsync = async _ => await gate.Task.ConfigureAwait(false) + }); + engine.Start(); + + engine.AppendBlocking(new byte[] { 1 }); + + Assert.CatchAsync( + async () => await engine.FlushAsync(TimeSpan.FromMilliseconds(150))); + + gate.TrySetResult(OkResponse(0)); + await engine.FlushAsync(TimeSpan.FromSeconds(5)); + } + + [Test] + public void Backpressure_DeadlineExpired_Throws() + { + // 64-byte segment fits exactly two 24-byte payloads (envelope = 8 header + 24 body = 32). The + // third append would rotate; with maxTotalBytes=64 the new segment can't be allocated → backpressure. + using var sendGate = new SemaphoreSlim(0, int.MaxValue); + using var engine = NewEngine(out _, + segmentCapacity: QwpMmapSegment.HeaderSize + 64, + maxTotalBytes: QwpMmapSegment.HeaderSize + 64, + appendDeadline: TimeSpan.FromMilliseconds(100), + factory: () => new StubTransport + { + OnSendGate = ct => sendGate.WaitAsync(ct) + }); + engine.Start(); + + engine.AppendBlocking(new byte[24]); + engine.AppendBlocking(new byte[24]); + + var ex = Assert.Catch(() => engine.AppendBlocking(new byte[24])); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ServerFlushError)); + Assert.That(ex.Message, Does.Contain("sf_append_deadline")); + + sendGate.Release(8); // unblock loop so Dispose joins promptly + } + + [Test] + public async Task Backpressure_BlocksUntilTrim() + { + using var sendGate = new SemaphoreSlim(0, int.MaxValue); + using var engine = NewEngine(out _, + segmentCapacity: QwpMmapSegment.HeaderSize + 64, + maxTotalBytes: QwpMmapSegment.HeaderSize + 64, + appendDeadline: TimeSpan.FromSeconds(10), + factory: () => new StubTransport + { + OnSendGate = ct => sendGate.WaitAsync(ct) + }); + engine.Start(); + + engine.AppendBlocking(new byte[24]); + engine.AppendBlocking(new byte[24]); + + var producer = Task.Run(() => engine.AppendBlocking(new byte[24])); + await Task.Delay(100); + Assert.That(producer.IsCompleted, Is.False, "third append should still be blocked"); + + // Release both queued sends — once FSN=1 is acked, seg0 (FSNs 0,1) is fully covered and + // gets trimmed, freeing space for FSN=2. + sendGate.Release(2); + + await producer.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(producer.IsCompletedSuccessfully, Is.True); + sendGate.Release(8); + } + + [Test] + public async Task Dispose_WakesBlockedProducerWithDisposedException() + { + using var sendGate = new SemaphoreSlim(0, int.MaxValue); + var engine = NewEngine(out _, + segmentCapacity: QwpMmapSegment.HeaderSize + 64, + maxTotalBytes: QwpMmapSegment.HeaderSize + 64, + appendDeadline: TimeSpan.FromSeconds(30), + factory: () => new StubTransport + { + OnSendGate = ct => sendGate.WaitAsync(ct) + }); + engine.Start(); + + engine.AppendBlocking(new byte[24]); + engine.AppendBlocking(new byte[24]); + + var producer = Task.Run(() => + { + try + { + engine.AppendBlocking(new byte[24]); + return null; + } + catch (ObjectDisposedException ex) + { + return ex; + } + }); + await Task.Delay(100); + Assert.That(producer.IsCompleted, Is.False); + + engine.Dispose(); + sendGate.Release(8); + + var thrown = await producer.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(thrown, Is.InstanceOf()); + } + + [Test] + public async Task ReconnectCycles_DisposeEveryTransport_NoLeak() + { + var stubs = new System.Collections.Concurrent.ConcurrentBag(); + var connectCount = 0; + + using var engine = NewEngine(out _, + factory: () => + { + var idx = Interlocked.Increment(ref connectCount); + var s = new StubTransport(); + if (idx <= 5) + { + var sent = 0; + s.OnSend = _ => + { + sent++; + if (sent == 1) return OkResponse(0); + throw new IngressError(ErrorCode.SocketError, $"connection {idx} drops on second send"); + }; + } + + stubs.Add(s); + return s; + }); + engine.Start(); + + for (var i = 0; i < 12; i++) + { + engine.AppendBlocking(new byte[] { (byte)i }); + } + + await engine.FlushAsync(TimeSpan.FromSeconds(10)); + engine.Dispose(); + + Assert.That(stubs, Has.Count.GreaterThanOrEqualTo(2)); + Assert.That(stubs.All(s => s.Disposed), Is.True, + $"every transport must be disposed; leaked {stubs.Count(s => !s.Disposed)} of {stubs.Count}"); + } + + [Test] + public async Task MultipleProducers_AllFramesEventuallyDrained() + { + const int producerCount = 4; + const int framesPerProducer = 250; + const int totalFrames = producerCount * framesPerProducer; + + using var engine = NewEngine(out _, segmentCapacity: 64 * 1024); + engine.Start(); + + var producerTasks = Enumerable.Range(0, producerCount).Select(p => Task.Run(() => + { + for (var i = 0; i < framesPerProducer; i++) + { + engine.AppendBlocking(new byte[] { (byte)p, (byte)(i & 0xFF) }); + } + })).ToArray(); + + await Task.WhenAll(producerTasks).WaitAsync(TimeSpan.FromSeconds(20)); + await engine.FlushAsync(TimeSpan.FromSeconds(20)); + + Assert.That(engine.NextFsn, Is.EqualTo((long)totalFrames)); + Assert.That(engine.AckedFsn, Is.EqualTo((long)totalFrames)); + } + + [Test] + public async Task StressReconnects_NoFramesLost() + { + const int totalFrames = 200; + var failureRate = 7; + var stubs = new System.Collections.Concurrent.ConcurrentBag(); + + using var engine = NewEngine(out _, + segmentCapacity: 64 * 1024, + policy: new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(5), TimeSpan.FromMinutes(2)), + factory: () => + { + var s = new StubTransport(); + var sendCount = 0; + s.OnSend = _ => + { + var n = Interlocked.Increment(ref sendCount); + if (n % failureRate == 0) + { + throw new IngressError(ErrorCode.SocketError, "synthetic flap"); + } + + return OkResponse(n - 1); + }; + stubs.Add(s); + return s; + }); + engine.Start(); + + for (var i = 0; i < totalFrames; i++) + { + var payload = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(payload, i); + engine.AppendBlocking(payload); + } + + await engine.FlushAsync(TimeSpan.FromSeconds(60)); + Assert.That(engine.AckedFsn, Is.EqualTo((long)totalFrames)); + Assert.That(stubs.Count, Is.GreaterThan(1), "synthetic flaps must have triggered at least one reconnect"); + + var perTransport = stubs + .Select(s => (IReadOnlyList)s.Sent.Select(b => BinaryPrimitives.ReadInt32LittleEndian(b)).ToList()) + .ToList(); + AssertAtLeastOnceDelivery(perTransport, expectedCount: totalFrames); + } + + // At-least-once: each connection sends frames in strictly ascending FSN order (no in-connection + // reorder), and the union across all connections covers every sequence 0..expectedCount-1 with + // no extras. Cross-connection duplicates are tolerated — replay is at-least-once. + private static void AssertAtLeastOnceDelivery(IEnumerable> perTransport, int expectedCount) + { + var union = new HashSet(); + foreach (var sent in perTransport) + { + for (var i = 1; i < sent.Count; i++) + { + Assert.That(sent[i], Is.GreaterThan(sent[i - 1]), + "a single connection must send frames in strictly ascending FSN order"); + } + + foreach (var seq in sent) + { + union.Add(seq); + } + } + + Assert.That(union, Is.EquivalentTo(Enumerable.Range(0, expectedCount)), + "every expected sequence number must appear at least once across all connections, with no extras"); + } + + + private QwpCursorSendEngine NewEngine( + out string slotDir, + Func? factory = null, + QwpReconnectPolicy? policy = null, + TimeSpan? appendDeadline = null, + InitialConnectMode initialConnectMode = InitialConnectMode.off, + long segmentCapacity = 4096, + long maxTotalBytes = long.MaxValue, + string? slotDirectoryOverride = null, + SenderErrorHandler? errorHandler = null, + SenderErrorPolicyResolver? policyResolver = null, + int errorInboxCapacity = 256) + { + slotDir = slotDirectoryOverride ?? Path.Combine(_root, "sender-" + Guid.NewGuid().ToString("N")); + var slotLock = QwpSlotLock.Acquire(slotDir); + var ring = QwpSegmentRing.Open(slotDir, segmentCapacity: segmentCapacity); + policy ??= new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(10), + TimeSpan.FromMilliseconds(40), + TimeSpan.FromSeconds(2)); + factory ??= () => new StubTransport(); + var dispatcher = errorHandler is null && policyResolver is null + ? null + : new QwpSenderErrorDispatcher(errorHandler, errorInboxCapacity); + return new QwpCursorSendEngine( + slotLock, ring, factory, policy, + appendDeadline ?? TimeSpan.FromSeconds(5), + initialConnectMode, + maxTotalBytes: maxTotalBytes, + errorDispatcher: dispatcher, + policyResolver: policyResolver); + } + + private static byte[] OkResponse(long sequence) + { + var buf = new byte[11]; + buf[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(9, 2), 0); + return buf; + } + + private static byte[] ErrorResponse(QwpStatusCode status, long sequence, string message) + { + var msgBytes = System.Text.Encoding.UTF8.GetBytes(message); + var buf = new byte[11 + msgBytes.Length]; + buf[0] = (byte)status; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(1, 8), sequence); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(9, 2), (ushort)msgBytes.Length); + msgBytes.CopyTo(buf.AsSpan(11)); + return buf; + } + + private static void AssertEventually(Func condition, string message, int timeoutMs = 1000, int pollMs = 10) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + Thread.Sleep(pollMs); + } + + Assert.Fail(message); + } + + private sealed class StubTransport : IQwpCursorTransport + { + public Func? OnConnect; + public Func? OnSend; + public Func>? OnSendAsync; + public Func? OnSendGate; + public List Sent { get; } = new(); + public (string Host, int Port)? Endpoint { get; set; } = ("stub", 0); + + private readonly Channel _acks = Channel.CreateUnbounded(); + private readonly object _sentLock = new(); + private int _autoSeq; + public bool Disposed { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken) + { + return OnConnect is null ? Task.CompletedTask : OnConnect(cancellationToken); + } + + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + var copy = data.ToArray(); + lock (_sentLock) { Sent.Add(copy); } + + if (OnSendGate is not null) + { + await OnSendGate(cancellationToken).ConfigureAwait(false); + } + + byte[] ack; + if (OnSendAsync is not null) + { + ack = await OnSendAsync(copy).ConfigureAwait(false); + } + else if (OnSend is not null) + { + ack = OnSend(copy); + } + else + { + ack = DefaultOk(Interlocked.Increment(ref _autoSeq) - 1); + } + + await _acks.Writer.WriteAsync(ack, cancellationToken).ConfigureAwait(false); + } + + public async Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken) + { + var ack = await _acks.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + ack.CopyTo(destination.Span); + return ack.Length; + } + + public Task CloseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() + { + Disposed = true; + _acks.Writer.TryComplete(); + } + + private static byte[] DefaultOk(int seq) + { + var buf = new byte[11]; + buf[0] = (byte)QwpStatusCode.Ok; + BinaryPrimitives.WriteInt64LittleEndian(buf.AsSpan(1, 8), seq); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(9, 2), 0); + return buf; + } + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpErrorClassifierTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpErrorClassifierTests.cs new file mode 100644 index 00000000..3d34b60c --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpErrorClassifierTests.cs @@ -0,0 +1,83 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpErrorClassifierTests +{ + [TestCase(QwpStatusCode.SchemaMismatch, SenderErrorCategory.SchemaMismatch)] + [TestCase(QwpStatusCode.ParseError, SenderErrorCategory.ParseError)] + [TestCase(QwpStatusCode.InternalError, SenderErrorCategory.InternalError)] + [TestCase(QwpStatusCode.SecurityError, SenderErrorCategory.SecurityError)] + [TestCase(QwpStatusCode.WriteError, SenderErrorCategory.WriteError)] + [TestCase((QwpStatusCode)0xFF, SenderErrorCategory.Unknown)] + public void Classify_ReturnsExpectedCategory(QwpStatusCode status, SenderErrorCategory expected) + { + Assert.That(QwpErrorClassifier.Classify(status), Is.EqualTo(expected)); + } + + [TestCase(SenderErrorCategory.SchemaMismatch, SenderErrorPolicy.DropAndContinue)] + [TestCase(SenderErrorCategory.WriteError, SenderErrorPolicy.DropAndContinue)] + [TestCase(SenderErrorCategory.ParseError, SenderErrorPolicy.Halt)] + [TestCase(SenderErrorCategory.InternalError, SenderErrorPolicy.Halt)] + [TestCase(SenderErrorCategory.SecurityError, SenderErrorPolicy.Halt)] + [TestCase(SenderErrorCategory.ProtocolViolation, SenderErrorPolicy.Halt)] + [TestCase(SenderErrorCategory.Unknown, SenderErrorPolicy.Halt)] + public void DefaultPolicy_MatchesSpec(SenderErrorCategory category, SenderErrorPolicy expected) + { + Assert.That(QwpErrorClassifier.DefaultPolicy(category), Is.EqualTo(expected)); + } + + [Test] + public void ResolvePolicy_NullResolver_FallsBackToDefault() + { + Assert.That( + QwpErrorClassifier.ResolvePolicy(SenderErrorCategory.SchemaMismatch, resolver: null), + Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + } + + [Test] + public void ResolvePolicy_ResolverWins_ForOverridableCategories() + { + Assert.That( + QwpErrorClassifier.ResolvePolicy( + SenderErrorCategory.SchemaMismatch, + _ => SenderErrorPolicy.Halt), + Is.EqualTo(SenderErrorPolicy.Halt)); + } + + [TestCase(SenderErrorCategory.ProtocolViolation)] + [TestCase(SenderErrorCategory.Unknown)] + public void ResolvePolicy_AlwaysHalt_ForFatalCategories(SenderErrorCategory category) + { + Assert.That( + QwpErrorClassifier.ResolvePolicy(category, _ => SenderErrorPolicy.DropAndContinue), + Is.EqualTo(SenderErrorPolicy.Halt)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpFallocateTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpFallocateTests.cs new file mode 100644 index 00000000..281ebae1 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpFallocateTests.cs @@ -0,0 +1,123 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpFallocateTests +{ + [Test] + public void Reserve_ZeroLength_NoOp() + { + var path = Path.Combine(Path.GetTempPath(), $"qwp-fallocate-{Guid.NewGuid():N}"); + using (var fs = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite)) + { + QwpFallocate.Reserve(fs, 0); + Assert.That(fs.Length, Is.EqualTo(0)); + } + File.Delete(path); + } + + [Test] + public void Reserve_ExtendsFileToRequestedLength() + { + var path = Path.Combine(Path.GetTempPath(), $"qwp-fallocate-{Guid.NewGuid():N}"); + try + { + using (var fs = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite)) + { + QwpFallocate.Reserve(fs, 256 * 1024); + Assert.That(fs.Length, Is.EqualTo(256 * 1024)); + } + // Posix preallocate / page-walk fallback both leave the file with real bytes the OS can + // serve without a SIGBUS on a subsequent mmap write; only the size is observable here. + var info = new FileInfo(path); + Assert.That(info.Length, Is.EqualTo(256 * 1024)); + } + finally + { + File.Delete(path); + } + } + + [Test] + public void Reserve_PreservesExistingBytes() + { + var path = Path.Combine(Path.GetTempPath(), $"qwp-fallocate-{Guid.NewGuid():N}"); + try + { + using (var fs = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite)) + { + Span probe = stackalloc byte[16]; + for (var i = 0; i < probe.Length; i++) probe[i] = (byte)(i + 1); + fs.Write(probe); + fs.Flush(true); + + QwpFallocate.Reserve(fs, 1024 * 1024); + Assert.That(fs.Length, Is.EqualTo(1024 * 1024)); + + fs.Position = 0; + Span read = stackalloc byte[16]; + Assert.That(fs.Read(read), Is.EqualTo(16)); + for (var i = 0; i < read.Length; i++) + { + Assert.That(read[i], Is.EqualTo((byte)(i + 1)), + "preallocation must not clobber bytes that were already written"); + } + } + } + finally + { + File.Delete(path); + } + } + + [Test] + public void Reserve_IdempotentWhenAlreadyAtLength() + { + var path = Path.Combine(Path.GetTempPath(), $"qwp-fallocate-{Guid.NewGuid():N}"); + try + { + using (var fs = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite)) + { + fs.SetLength(64 * 1024); + QwpFallocate.Reserve(fs, 64 * 1024); + Assert.That(fs.Length, Is.EqualTo(64 * 1024)); + QwpFallocate.Reserve(fs, 64 * 1024); + Assert.That(fs.Length, Is.EqualTo(64 * 1024)); + } + } + finally + { + File.Delete(path); + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpFilesTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpFilesTests.cs new file mode 100644 index 00000000..f4fc9c33 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpFilesTests.cs @@ -0,0 +1,171 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpFilesTests +{ + private string _tempDir = null!; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "qwp-files-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + [Test] + public void OpenExclusive_GrantsAccessToFirstOpener() + { + var path = Path.Combine(_tempDir, "lock"); + using var first = QwpFiles.OpenExclusive(path); + Assert.That(first.CanWrite); + } + + [Test] + public void TryOpenExclusive_ReturnsNullOnLockCollision() + { + var path = Path.Combine(_tempDir, "lock"); + using var first = QwpFiles.OpenExclusive(path); + + var second = QwpFiles.TryOpenExclusive(path); + Assert.That(second, Is.Null, "second open should fail because first holds the exclusive share"); + } + + [Test] + public void TryOpenExclusive_MissingDirectory_PropagatesNotNullReturn() + { + var nestedMissing = Path.Combine(_tempDir, "no-such-dir", "lock"); + Assert.Throws(() => QwpFiles.TryOpenExclusive(nestedMissing)); + } + + [Test] + public void TryOpenExclusive_SucceedsAfterFirstReleases() + { + var path = Path.Combine(_tempDir, "lock"); + var first = QwpFiles.OpenExclusive(path); + first.Dispose(); + + using var second = QwpFiles.TryOpenExclusive(path); + Assert.That(second, Is.Not.Null); + } + + [Test] + public void EnsureFileLength_ExtendsButDoesNotShrink() + { + var path = Path.Combine(_tempDir, "data"); + QwpFiles.EnsureFileLength(path, 1024); + Assert.That(new FileInfo(path).Length, Is.EqualTo(1024)); + + // Re-call with smaller size: must not shrink. + QwpFiles.EnsureFileLength(path, 512); + Assert.That(new FileInfo(path).Length, Is.EqualTo(1024)); + + // Re-call with larger size: extends. + QwpFiles.EnsureFileLength(path, 4096); + Assert.That(new FileInfo(path).Length, Is.EqualTo(4096)); + } + + [Test] + public void Truncate_ShrinksFile() + { + var path = Path.Combine(_tempDir, "data"); + QwpFiles.EnsureFileLength(path, 4096); + QwpFiles.Truncate(path, 1024); + Assert.That(new FileInfo(path).Length, Is.EqualTo(1024)); + } + + [Test] + public void OpenMemoryMappedSegment_RoundTripsBytes() + { + var path = Path.Combine(_tempDir, "segment"); + const int capacity = 8192; + + { + var (mmap, fs) = QwpFiles.OpenMemoryMappedSegment(path, capacity); + using (mmap) + using (fs) + using (var view = mmap.CreateViewAccessor(0, capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.ReadWrite)) + { + view.Write(0, 0xDEADBEEFu); + view.Write(4, 0x12345678u); + view.Flush(); + } + } + + // Reopen and verify the writes survived. + { + var (mmap, fs) = QwpFiles.OpenMemoryMappedSegment(path, capacity); + using (mmap) + using (fs) + using (var view = mmap.CreateViewAccessor(0, capacity, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.Read)) + { + Assert.That(view.ReadUInt32(0), Is.EqualTo(0xDEADBEEFu)); + Assert.That(view.ReadUInt32(4), Is.EqualTo(0x12345678u)); + } + } + } + + [Test] + public void EnumerateSlotDirectories_ListsImmediateSubdirsOnly() + { + Directory.CreateDirectory(Path.Combine(_tempDir, "slot-a")); + Directory.CreateDirectory(Path.Combine(_tempDir, "slot-b")); + Directory.CreateDirectory(Path.Combine(_tempDir, "slot-b", "nested")); + + var slots = QwpFiles.EnumerateSlotDirectories(_tempDir).Select(Path.GetFileName).OrderBy(n => n).ToList(); + Assert.That(slots, Is.EqualTo(new[] { "slot-a", "slot-b" })); + } + + [Test] + public void EnumerateSlotDirectories_AbsentRoot_ReturnsEmpty() + { + Assert.That(QwpFiles.EnumerateSlotDirectories(Path.Combine(_tempDir, "nope")), Is.Empty); + } + + [Test] + public void Delete_AbsentFile_IsNoOp() + { + Assert.DoesNotThrow(() => QwpFiles.Delete(Path.Combine(_tempDir, "missing"))); + } + + [Test] + public void PageSize_IsPositive() + { + Assert.That(QwpFiles.PageSize, Is.GreaterThan(0)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpMemorySegmentTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpMemorySegmentTests.cs new file mode 100644 index 00000000..11eb3ebf --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpMemorySegmentTests.cs @@ -0,0 +1,224 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Reflection; +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpMemorySegmentTests +{ + private const int EnvelopeHeaderSize = QwpMemorySegment.EnvelopeHeaderSize; + + [Test] + public void Allocate_FreshSegment_HasZeroEnvelopes() + { + using var seg = QwpMemorySegment.Allocate(capacity: 4096, baseFsn: 0); + + Assert.That(seg.WritePosition, Is.Zero); + Assert.That(seg.EnvelopeCount, Is.Zero); + Assert.That(seg.NextFsn, Is.Zero); + Assert.That(seg.IsSealed, Is.False); + Assert.That(seg.Capacity, Is.EqualTo(4096)); + } + + [Test] + public void Allocate_CapacityNotExceedingHeader_Throws() + { + Assert.Throws( + () => QwpMemorySegment.Allocate(capacity: EnvelopeHeaderSize, baseFsn: 0)); + } + + [Test] + public void Allocate_CapacityAboveCap_Throws() + { + Assert.Throws( + () => QwpMemorySegment.Allocate(capacity: QwpMemorySegment.MaxCapacity + 1, baseFsn: 0)); + } + + [Test] + public void Append_OneFrame_RoundTrips() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 100); + var frame = new byte[] { 1, 2, 3, 4, 5 }; + + Assert.That(seg.TryAppend(frame), Is.True); + Assert.That(seg.EnvelopeCount, Is.EqualTo(1)); + Assert.That(seg.WritePosition, Is.EqualTo(EnvelopeHeaderSize + 5)); + Assert.That(seg.NextFsn, Is.EqualTo(101)); + + var dest = new byte[64]; + var read = seg.TryReadFrame(0, dest, out var fsn); + Assert.That(read, Is.EqualTo(5)); + Assert.That(fsn, Is.EqualTo(100L)); + Assert.That(dest.AsSpan(0, read).ToArray(), Is.EqualTo(frame)); + } + + [Test] + public void Append_MultipleFrames_AllReadableBackInOrder() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + + var frames = Enumerable.Range(0, 10) + .Select(i => Enumerable.Range(0, i + 1).Select(b => (byte)b).ToArray()) + .ToList(); + + foreach (var f in frames) + { + Assert.That(seg.TryAppend(f), Is.True); + } + + Assert.That(seg.EnvelopeCount, Is.EqualTo(frames.Count)); + + var dest = new byte[64]; + for (var i = 0; i < frames.Count; i++) + { + var offset = seg.OffsetOfEnvelope(i); + Assert.That(offset, Is.Not.Null); + var len = seg.TryReadFrame(offset!.Value, dest, out var fsn); + Assert.That(len, Is.EqualTo(frames[i].Length)); + Assert.That(fsn, Is.EqualTo((long)i)); + Assert.That(dest.AsSpan(0, len).ToArray(), Is.EqualTo(frames[i])); + } + } + + [Test] + public void Append_BeyondCapacity_ReturnsFalse() + { + const int bodyRoom = 32; + using var seg = QwpMemorySegment.Allocate(EnvelopeHeaderSize + bodyRoom, baseFsn: 0); + + Assert.That(seg.TryAppend(new byte[20]), Is.True); + Assert.That(seg.TryAppend(new byte[20]), Is.False, + "second 20-byte frame needs 28 bytes; only 12 left"); + Assert.That(seg.EnvelopeCount, Is.EqualTo(1), "the failed append must not increase the envelope count"); + } + + [Test] + public void Append_EmptyFrame_Throws() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + Assert.Throws(() => seg.TryAppend(ReadOnlySpan.Empty)); + } + + [Test] + public void Append_FrameLargerThanMaxFrameLength_Throws() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0, maxFrameLength: 64); + Assert.Throws(() => seg.TryAppend(new byte[65])); + } + + [Test] + public void TryReadFrame_OffsetPastEnd_ReturnsMinusOne() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + seg.TryAppend(new byte[] { 1, 2, 3 }); + + var dest = new byte[64]; + Assert.That(seg.TryReadFrame(99999, dest, out _), Is.EqualTo(-1)); + } + + [Test] + public void TryReadFrame_DestinationTooSmall_Throws() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + seg.TryAppend(new byte[] { 1, 2, 3, 4, 5 }); + + var dest = new byte[2]; + Assert.Throws(() => seg.TryReadFrame(0, dest, out _)); + } + + [Test] + public void TryReadFrame_AtMidEnvelopeOffset_IsRejected() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + seg.TryAppend(new byte[] { 1, 2, 3, 4, 5, 6 }); + + // Reading one byte into the envelope re-interprets later bytes as the length field; + // that decodes to an out-of-range length, so the read is rejected. + var dest = new byte[64]; + Assert.Throws(() => seg.TryReadFrame(1, dest, out _)); + } + + [Test] + public void Seal_PreventsFurtherAppends() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + seg.TryAppend(new byte[] { 1 }); + seg.Seal(); + + Assert.That(seg.IsSealed, Is.True); + Assert.Throws(() => seg.TryAppend(new byte[] { 2 })); + } + + [Test] + public void TryAppend_AfterDispose_Throws() + { + var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + seg.Dispose(); + + Assert.Throws(() => seg.TryAppend(new byte[] { 1 })); + } + + [Test] + [NonParallelizable] + public void Dispose_FreesNativeMemory_BalancingTheLiveByteCounter() + { + // The segment is a "dumb" resource with no finalizer: it must free its native buffer on + // Dispose, and its holder (the ring) is responsible for calling that. A dropped-without- + // Dispose segment intentionally leaks here — that path is covered by the ring finalizer test + // in QwpSegmentRingTests. + var baseline = Interlocked.Read(ref QwpMemorySegment.LiveNativeBytes); + + const long capacity = 1L << 20; // 1 MiB + var seg = QwpMemorySegment.Allocate(capacity, baseFsn: 0); + Assert.That(Interlocked.Read(ref QwpMemorySegment.LiveNativeBytes) - baseline, + Is.EqualTo(capacity), "Allocate must account the native buffer"); + + seg.Dispose(); + Assert.That(Interlocked.Read(ref QwpMemorySegment.LiveNativeBytes) - baseline, + Is.Zero, "Dispose must free the native buffer and balance the counter"); + + seg.Dispose(); // idempotent: a second Dispose must not double-decrement + Assert.That(Interlocked.Read(ref QwpMemorySegment.LiveNativeBytes) - baseline, Is.Zero); + } + + [Test] + public unsafe void TryReadFrame_VerifiesCrc_OnInMemoryCorruption() + { + using var seg = QwpMemorySegment.Allocate(4096, baseFsn: 0); + seg.TryAppend(new byte[] { 1, 2, 3, 4 }); + + // Flip the first body byte directly in the native buffer, leaving the stored CRC stale. + var basePtrField = typeof(QwpMemorySegment).GetField( + "_basePtr", BindingFlags.NonPublic | BindingFlags.Instance)!; + var basePtr = (byte*)Pointer.Unbox(basePtrField.GetValue(seg)!); + basePtr[EnvelopeHeaderSize] ^= 0x55; + + var dest = new byte[64]; + Assert.Throws(() => seg.TryReadFrame(0, dest, out _)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpMmapSegmentTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpMmapSegmentTests.cs new file mode 100644 index 00000000..5d9bfceb --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpMmapSegmentTests.cs @@ -0,0 +1,366 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpMmapSegmentTests +{ + private string _tempDir = null!; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), "qwp-segment-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + [Test] + public void Open_FreshFile_HasZeroEnvelopes() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), capacity: 4096, baseFsn: 0); + + Assert.That(seg.WritePosition, Is.EqualTo(QwpMmapSegment.HeaderSize)); + Assert.That(seg.EnvelopeCount, Is.Zero); + Assert.That(seg.NextFsn, Is.Zero); + Assert.That(seg.IsSealed, Is.False); + } + + [Test] + public void Append_OneFrame_RoundTrips() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), 4096, 100); + var frame = new byte[] { 1, 2, 3, 4, 5 }; + + Assert.That(seg.TryAppend(frame), Is.True); + Assert.That(seg.EnvelopeCount, Is.EqualTo(1)); + Assert.That(seg.WritePosition, Is.EqualTo(QwpMmapSegment.HeaderSize + 8 + 5)); + Assert.That(seg.NextFsn, Is.EqualTo(101)); + + var dest = new byte[64]; + var read = seg.TryReadFrame(QwpMmapSegment.HeaderSize, dest, out var fsn); + Assert.That(read, Is.EqualTo(5)); + Assert.That(fsn, Is.EqualTo(100L)); + Assert.That(dest.AsSpan(0, read).ToArray(), Is.EqualTo(frame)); + } + + [Test] + public void Append_MultipleFrames_AllReadableBackInOrder() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), 4096, 0); + + var frames = Enumerable.Range(0, 10) + .Select(i => Enumerable.Range(0, i + 1).Select(b => (byte)b).ToArray()) + .ToList(); + + foreach (var f in frames) + { + Assert.That(seg.TryAppend(f), Is.True); + } + + Assert.That(seg.EnvelopeCount, Is.EqualTo(frames.Count)); + + long offset = QwpMmapSegment.HeaderSize; + var dest = new byte[64]; + for (var i = 0; i < frames.Count; i++) + { + var len = seg.TryReadFrame(offset, dest, out _); + Assert.That(len, Is.EqualTo(frames[i].Length)); + Assert.That(dest.AsSpan(0, len).ToArray(), Is.EqualTo(frames[i])); + offset += 8 + len; + } + } + + [Test] + public void Append_BeyondCapacity_ReturnsFalse() + { + // Capacity = HeaderSize + space for one envelope of header(8) + body up to 24 bytes. + const int bodyRoom = 32; + using var seg = QwpMmapSegment.Open(SegmentPath(), QwpMmapSegment.HeaderSize + bodyRoom, 0); + + Assert.That(seg.TryAppend(new byte[20]), Is.True); + Assert.That(seg.TryAppend(new byte[20]), Is.False, "second 20-byte frame needs 28 bytes; only 4 left"); + Assert.That(seg.EnvelopeCount, Is.EqualTo(1), "the failed append must not increase the envelope count"); + } + + [Test] + public void Append_EmptyFrame_Throws() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), 4096, 0); + Assert.Throws(() => seg.TryAppend(ReadOnlySpan.Empty)); + } + + [Test] + public void Reopen_WithLoweredCapacityBelowFileSize_ThrowsInsteadOfTruncatingReplay() + { + var path = SegmentPath(); + + // Write a segment, then fill it with several frames so it is meaningfully larger than the + // lowered capacity we reopen with. + using (var seg = QwpMmapSegment.Open(path, 8192, 100)) + { + for (var i = 0; i < 50; i++) + { + seg.TryAppend(new byte[] { (byte)i, 1, 2, 3 }); + } + } + + // Reopening at a capacity smaller than the on-disk file would map only a prefix and silently + // drop the trailing un-acked envelopes from replay. Must fail loudly instead. + var ex = Assert.Throws(() => QwpMmapSegment.Open(path, 4096, 100)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ConfigError)); + } + + [Test] + public void Reopen_RecoversWritePositionAndEnvelopeCount() + { + var path = SegmentPath(); + + using (var seg = QwpMmapSegment.Open(path, 4096, 100)) + { + seg.TryAppend(new byte[] { 10 }); + seg.TryAppend(new byte[] { 20, 21 }); + seg.TryAppend(new byte[] { 30, 31, 32 }); + } + + using var reopened = QwpMmapSegment.Open(path, 4096, 100); + Assert.That(reopened.EnvelopeCount, Is.EqualTo(3)); + // 3 envelopes × 8-byte header + (1 + 2 + 3) bytes payload = 30 envelope bytes after the file header. + Assert.That(reopened.WritePosition, Is.EqualTo(QwpMmapSegment.HeaderSize + 30)); + Assert.That(reopened.NextFsn, Is.EqualTo(103)); + } + + [Test] + public void Reopen_AfterCorruptedFrame_TruncatesToLastGood() + { + var path = SegmentPath(); + + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 1, 2, 3 }); + seg.TryAppend(new byte[] { 4, 5, 6 }); + seg.TryAppend(new byte[] { 7, 8, 9 }); + } + + var bytes = File.ReadAllBytes(path); + var firstEnvSize = 8 + 3; + bytes[QwpMmapSegment.HeaderSize + firstEnvSize] ^= 0xFF; + File.WriteAllBytes(path, bytes); + + using var reopened = QwpMmapSegment.Open(path, 4096, 0); + Assert.That(reopened.EnvelopeCount, Is.EqualTo(1), "replay must stop at the corruption"); + Assert.That(reopened.WritePosition, Is.EqualTo(QwpMmapSegment.HeaderSize + firstEnvSize)); + } + + [Test] + public void Reopen_AfterTornTail_TruncatesToLastGood() + { + var path = SegmentPath(); + + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 1, 2, 3 }); + } + + var bytes = File.ReadAllBytes(path); + var firstEnvSize = 8 + 3; + BitConverter.TryWriteBytes(bytes.AsSpan(QwpMmapSegment.HeaderSize + firstEnvSize, 4), 0u); + BitConverter.TryWriteBytes(bytes.AsSpan(QwpMmapSegment.HeaderSize + firstEnvSize + 4, 4), 999_999); + File.WriteAllBytes(path, bytes); + + using var reopened = QwpMmapSegment.Open(path, 4096, 0); + Assert.That(reopened.EnvelopeCount, Is.EqualTo(1)); + Assert.That(reopened.WritePosition, Is.EqualTo(QwpMmapSegment.HeaderSize + firstEnvSize)); + } + + [Test] + public void AppendAfterReopenWithCorruption_OverwritesTornBytes() + { + var path = SegmentPath(); + + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 1, 2, 3 }); + seg.TryAppend(new byte[] { 4, 5, 6 }); + } + + var bytes = File.ReadAllBytes(path); + bytes[QwpMmapSegment.HeaderSize + 8 + 3] ^= 0xFF; + File.WriteAllBytes(path, bytes); + + using (var reopened = QwpMmapSegment.Open(path, 4096, 0)) + { + Assert.That(reopened.EnvelopeCount, Is.EqualTo(1)); + Assert.That(reopened.TryAppend(new byte[] { 99, 99, 99 }), Is.True); + Assert.That(reopened.EnvelopeCount, Is.EqualTo(2)); + } + + using var third = QwpMmapSegment.Open(path, 4096, 0); + Assert.That(third.EnvelopeCount, Is.EqualTo(2)); + + var dest = new byte[16]; + var firstOffset = third.OffsetOfEnvelope(0); + var secondOffset = third.OffsetOfEnvelope(1); + Assert.That(firstOffset, Is.Not.Null); + Assert.That(secondOffset, Is.Not.Null); + + var firstLen = third.TryReadFrame(firstOffset!.Value, dest, out _); + Assert.That(dest.AsSpan(0, firstLen).ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); + + var secondLen = third.TryReadFrame(secondOffset!.Value, dest, out _); + Assert.That(dest.AsSpan(0, secondLen).ToArray(), Is.EqualTo(new byte[] { 99, 99, 99 })); + } + + [Test] + public void Seal_PreventsFurtherAppends() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), 4096, 0); + seg.TryAppend(new byte[] { 1 }); + seg.Seal(); + + Assert.Throws(() => seg.TryAppend(new byte[] { 2 })); + } + + [Test] + public void Reopen_AfterSeal_IsNotSealed_RecoveryReliesOnRingOrdering() + { + var path = SegmentPath(); + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 1, 2, 3 }); + seg.Seal(); + Assert.That(seg.IsSealed, Is.True); + } + + using var reopened = QwpMmapSegment.Open(path, 4096, 0); + Assert.That(reopened.IsSealed, Is.False); + } + + [Test] + public void Reopen_FreshSegment_IsNotSealed() + { + var path = SegmentPath(); + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 1 }); + } + + using var reopened = QwpMmapSegment.Open(path, 4096, 0); + Assert.That(reopened.IsSealed, Is.False); + } + + [Test] + public void TryReadFrame_OffsetPastEnd_ReturnsMinusOne() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), 4096, 0); + seg.TryAppend(new byte[] { 1, 2, 3 }); + + var dest = new byte[64]; + Assert.That(seg.TryReadFrame(99999, dest, out _), Is.EqualTo(-1)); + } + + [Test] + public void TryReadFrame_DetectsOutOfBandCorruption_ThrowsInvalidData() + { + var path = SegmentPath(); + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 10, 20, 30, 40 }); + } + + // Corrupt the frame body of the only envelope, leaving the CRC + length untouched. Open()'s + // replay-time scanner now passes (different code path) but every TryReadFrame must fail. + var bytes = File.ReadAllBytes(path); + bytes[QwpMmapSegment.HeaderSize + QwpMmapSegment.EnvelopeHeaderSize + 0] ^= 0xFF; + File.WriteAllBytes(path, bytes); + + using var reopened = QwpMmapSegment.Open(path, 4096, 0); + // Envelope replay catches it at Open and truncates; with envelope removed, the read returns -1. + var dest = new byte[64]; + Assert.That(reopened.TryReadFrame(QwpMmapSegment.HeaderSize, dest, out _), Is.EqualTo(-1)); + } + + [Test] + public void TryReadFrame_VerifiesCrc_OnPostOpenCorruption() + { + var path = SegmentPath(); + using (var seg = QwpMmapSegment.Open(path, 4096, 0)) + { + seg.TryAppend(new byte[] { 1, 2, 3, 4 }); + } + + // mmap on Windows holds the file with exclusive sharing; release the segment before raw IO. + var bytes = File.ReadAllBytes(path); + bytes[QwpMmapSegment.HeaderSize + QwpMmapSegment.EnvelopeHeaderSize + 0] ^= 0x55; + File.WriteAllBytes(path, bytes); + + using var reopened = QwpMmapSegment.Open(path, 4096, 0); + Assert.That(reopened.EnvelopeCount, Is.EqualTo(0)); + } + + [Test] + public void Append_FrameLargerThanMaxFrameLength_Throws() + { + using var seg = QwpMmapSegment.Open(SegmentPath(), 4096, 0, maxFrameLength: 64); + Assert.Throws(() => seg.TryAppend(new byte[65])); + } + + [Test] + public void Reopen_RejectsEnvelopeLargerThanMaxFrameLength() + { + var path = SegmentPath(); + using (var seg = QwpMmapSegment.Open(path, 4096, 0, maxFrameLength: 4096)) + { + seg.TryAppend(new byte[200]); + } + + // Forge an oversized envelope after the legit one. With maxFrameLength=250, the legit + // 200-byte envelope is fine, but the forged 500-byte one is treated as a torn tail. + var bytes = File.ReadAllBytes(path); + var firstEnvelopeBodyEnd = QwpMmapSegment.HeaderSize + QwpMmapSegment.EnvelopeHeaderSize + 200; + BitConverter.GetBytes(500).CopyTo(bytes, firstEnvelopeBodyEnd + 4); + bytes[firstEnvelopeBodyEnd + 0] = 0xAB; + bytes[firstEnvelopeBodyEnd + 1] = 0xCD; + File.WriteAllBytes(path, bytes); + + using var reopened = QwpMmapSegment.Open(path, 4096, 0, maxFrameLength: 250); + Assert.That(reopened.EnvelopeCount, Is.EqualTo(1)); + } + + private string SegmentPath() => Path.Combine(_tempDir, "sf-0000000000000000.sfa"); +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpOrphanScannerTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpOrphanScannerTests.cs new file mode 100644 index 00000000..b6ad3808 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpOrphanScannerTests.cs @@ -0,0 +1,192 @@ + +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpOrphanScannerTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-orphan-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Test] + public void ClaimOrphans_AbsentRoot_ReturnsEmpty() + { + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + Assert.That(claimed, Is.Empty); + } + + [Test] + public void ClaimOrphans_SkipsOwnSlot() + { + var ownSlot = Path.Combine(_root, "self"); + SetupSlotWithSegment(ownSlot); + + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + Assert.That(claimed, Is.Empty); + } + + [Test] + public void ClaimOrphans_SkipsFailedSentinelSlot() + { + var slot = Path.Combine(_root, "crashed"); + SetupSlotWithSegment(slot); + File.WriteAllText(Path.Combine(slot, ".failed"), ""); + + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + Assert.That(claimed, Is.Empty); + } + + [Test] + public void ClaimOrphans_SkipsAlreadyLockedSlot() + { + var slot = Path.Combine(_root, "live"); + SetupSlotWithSegment(slot); + using var ownerLock = QwpSlotLock.Acquire(slot); + + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + Assert.That(claimed, Is.Empty); + } + + [Test] + public void ClaimOrphans_SkipsEmptySlot() + { + var slot = Path.Combine(_root, "empty"); + Directory.CreateDirectory(slot); + + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + Assert.That(claimed, Is.Empty); + } + + [Test] + public void ClaimOrphans_ClaimsEligibleOrphan() + { + var slot = Path.Combine(_root, "crashed"); + SetupSlotWithSegment(slot); + + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + try + { + Assert.That(claimed, Has.Count.EqualTo(1)); + Assert.That(claimed[0].SlotDirectory, Is.EqualTo(slot)); + } + finally + { + foreach (var c in claimed) + { + c.Dispose(); + } + } + } + + [Test] + public void ClaimOrphans_MultipleOrphans_PartialEligibility() + { + var ourSlot = Path.Combine(_root, "self"); + var goodA = Path.Combine(_root, "good-a"); + var goodB = Path.Combine(_root, "good-b"); + var failed = Path.Combine(_root, "failed"); + var live = Path.Combine(_root, "live"); + var empty = Path.Combine(_root, "empty"); + + SetupSlotWithSegment(ourSlot); + SetupSlotWithSegment(goodA); + SetupSlotWithSegment(goodB); + SetupSlotWithSegment(failed); + File.WriteAllText(Path.Combine(failed, ".failed"), "x"); + SetupSlotWithSegment(live); + using var liveLock = QwpSlotLock.Acquire(live); + Directory.CreateDirectory(empty); + + var claimed = QwpOrphanScanner.ClaimOrphans(_root, "self"); + try + { + var dirs = claimed.Select(l => l.SlotDirectory).OrderBy(s => s).ToList(); + Assert.That(dirs, Is.EqualTo(new[] { goodA, goodB })); + } + finally + { + foreach (var c in claimed) + { + c.Dispose(); + } + } + } + + [Test] + public void ClaimOrphans_LockAlreadyReleased_CanBeReclaimedByCaller() + { + var slot = Path.Combine(_root, "crashed"); + SetupSlotWithSegment(slot); + + var firstSweep = QwpOrphanScanner.ClaimOrphans(_root, "self"); + try + { + Assert.That(firstSweep, Has.Count.EqualTo(1)); + } + finally + { + foreach (var c in firstSweep) c.Dispose(); + } + + // After releasing, a second scan should re-claim the same slot. + var secondSweep = QwpOrphanScanner.ClaimOrphans(_root, "self"); + try + { + Assert.That(secondSweep, Has.Count.EqualTo(1)); + Assert.That(secondSweep[0].SlotDirectory, Is.EqualTo(slot)); + } + finally + { + foreach (var c in secondSweep) + { + c.Dispose(); + } + } + } + + private static void SetupSlotWithSegment(string slotDir) + { + Directory.CreateDirectory(slotDir); + File.WriteAllBytes(Path.Combine(slotDir, "sf-0000000000000000.sfa"), new byte[16]); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpReconnectPolicyTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpReconnectPolicyTests.cs new file mode 100644 index 00000000..ad78ebe0 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpReconnectPolicyTests.cs @@ -0,0 +1,247 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpReconnectPolicyTests +{ + [Test] + public void Constructor_NonPositiveInitialBackoff_Throws() + { + Assert.Throws( + () => new QwpReconnectPolicy(TimeSpan.Zero, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))); + Assert.Throws( + () => new QwpReconnectPolicy(TimeSpan.FromTicks(-1), TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))); + } + + [Test] + public void Constructor_MaxBackoffLessThanInitial_Throws() + { + Assert.Throws( + () => new QwpReconnectPolicy( + TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))); + } + + [Test] + public void Constructor_NegativeOutageDuration_Throws() + { + Assert.Throws( + () => new QwpReconnectPolicy( + TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromTicks(-1))); + } + + [Test] + public void ComputeBackoff_AttemptZero_ReturnsInitial() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(10), + TimeSpan.FromMinutes(1)); + + Assert.That(policy.ComputeBackoff(0), Is.EqualTo(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public void ComputeBackoff_DoublesPerAttempt() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(10), + TimeSpan.FromMinutes(5)); + + Assert.That(policy.ComputeBackoff(0), Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(policy.ComputeBackoff(1), Is.EqualTo(TimeSpan.FromMilliseconds(200))); + Assert.That(policy.ComputeBackoff(2), Is.EqualTo(TimeSpan.FromMilliseconds(400))); + Assert.That(policy.ComputeBackoff(3), Is.EqualTo(TimeSpan.FromMilliseconds(800))); + Assert.That(policy.ComputeBackoff(4), Is.EqualTo(TimeSpan.FromMilliseconds(1600))); + } + + [Test] + public void ComputeBackoff_CapsAtMaxBackoff() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(500), + TimeSpan.FromMinutes(5)); + + Assert.That(policy.ComputeBackoff(0), Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(policy.ComputeBackoff(1), Is.EqualTo(TimeSpan.FromMilliseconds(200))); + Assert.That(policy.ComputeBackoff(2), Is.EqualTo(TimeSpan.FromMilliseconds(400))); + // 800ms would exceed the 500ms cap → clamp. + Assert.That(policy.ComputeBackoff(3), Is.EqualTo(TimeSpan.FromMilliseconds(500))); + Assert.That(policy.ComputeBackoff(20), Is.EqualTo(TimeSpan.FromMilliseconds(500))); + } + + [Test] + public void ComputeBackoff_LargeAttemptIndex_DoesNotOverflow() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(30), + TimeSpan.FromHours(1)); + + Assert.That(policy.ComputeBackoff(1000), Is.EqualTo(TimeSpan.FromSeconds(30))); + } + + [Test] + public void ComputeBackoff_NegativeAttempt_Throws() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(10), + TimeSpan.FromMinutes(1)); + + Assert.Throws(() => policy.ComputeBackoff(-1)); + } + + [Test] + public void NextBackoffOrGiveUp_BudgetExhausted_ReturnsNull() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30)); + + Assert.That(policy.NextBackoffOrGiveUp(0, TimeSpan.FromSeconds(31)), Is.Null); + } + + [Test] + public void NextBackoffOrGiveUp_BeforeExhaustion_ReturnsBackoff() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30)); + + Assert.That( + policy.NextBackoffOrGiveUp(0, TimeSpan.FromSeconds(5)), + Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That( + policy.NextBackoffOrGiveUp(2, TimeSpan.FromSeconds(5)), + Is.EqualTo(TimeSpan.FromMilliseconds(400))); + } + + [Test] + public void NextBackoffOrGiveUp_BackoffWouldExceedBudget_ClipsToRemaining() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(10)); + + // Used 8s of the 10s budget → 2s remaining; computed backoff is 5s → clip to 2s. + var next = policy.NextBackoffOrGiveUp(0, TimeSpan.FromSeconds(8)); + Assert.That(next, Is.EqualTo(TimeSpan.FromSeconds(2))); + } + + [Test] + public void NextBackoffOrGiveUp_NoBudgetLeft_ReturnsNull() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(10)); + + // Exactly at the boundary — no remaining time after subtraction. + Assert.That(policy.NextBackoffOrGiveUp(0, TimeSpan.FromSeconds(10)), Is.Null); + } + + [Test] + public void Jitter_IdentityByDefault_DeterministicForTests() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10)); + + // No jitter passed → backoff is exactly the deterministic computation. + Assert.That(policy.ComputeBackoff(0), Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(policy.ComputeBackoff(0), Is.EqualTo(TimeSpan.FromMilliseconds(100))); + } + + [Test] + public void Jitter_Equal_SpreadsBackoffAcrossFullRange() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(10), + jitter: QwpReconnectPolicy.EqualJitter); + + var samples = Enumerable.Range(0, 64).Select(_ => policy.ComputeBackoff(0)).ToArray(); + + foreach (var s in samples) + { + Assert.That(s, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(s, Is.LessThan(TimeSpan.FromMilliseconds(200))); + } + + Assert.That(samples.Distinct().Count(), Is.GreaterThan(1), + "uniform jitter must produce varied samples — otherwise it's not actually random"); + } + + [Test] + public void Jitter_Equal_StillFiresWhenSaturated() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(10), + jitter: QwpReconnectPolicy.EqualJitter); + + var samples = Enumerable.Range(0, 64).Select(_ => policy.ComputeBackoff(10)).ToArray(); + + foreach (var s in samples) + { + // At saturation (base==max==100ms) jitter spans [100ms, 200ms). + Assert.That(s, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(s, Is.LessThan(TimeSpan.FromMilliseconds(200))); + } + + Assert.That(samples.Distinct().Count(), Is.GreaterThan(1), + "jitter must still vary samples once exponential growth saturates at MaxBackoff"); + } + + [Test] + public void Jitter_Full_SpreadsBackoffOverZeroToBase() + { + var policy = new QwpReconnectPolicy( + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(10), + jitter: QwpReconnectPolicy.FullJitter); + + var samples = Enumerable.Range(0, 64).Select(_ => policy.ComputeBackoff(0)).ToArray(); + + foreach (var s in samples) + { + Assert.That(s, Is.GreaterThanOrEqualTo(TimeSpan.Zero)); + Assert.That(s, Is.LessThanOrEqualTo(TimeSpan.FromMilliseconds(100))); + } + + Assert.That(samples.Distinct().Count(), Is.GreaterThan(1)); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentManagerTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentManagerTests.cs new file mode 100644 index 00000000..a17d489d --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentManagerTests.cs @@ -0,0 +1,268 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Runtime.InteropServices; +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpSegmentManagerTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-mgr-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + } + + [Test] + public async Task Provisions_HotSpare_OnFreshRing() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + using var mgr = new QwpSegmentManager(ring, long.MaxValue); + mgr.Start(); + + // Producer asks for spare via NeedsHotSpare → Wake → manager installs. + await WaitFor(() => mgr.SparesInstalled >= 1, TimeSpan.FromSeconds(2)); + Assert.That(ring.NeedsHotSpare(), Is.False); + Assert.That(mgr.CommittedBytes, Is.EqualTo((long)QwpMmapSegment.HeaderSize + 64)); + } + + [Test] + public async Task ProvisionedHotSpare_IsBlockReserved_NotSparse() + { + // Guards the ProvisionHotSpare ordering: Reserve must own the SetLength so the macOS + // F_PREALLOCATE path (which reserves blocks from the physical EOF) actually runs. A SetLength + // before Reserve leaves a sparse spare whose mmap-write SIGBUSes when the disk is full. The + // sparseness is only observable on Unix via on-disk block accounting (du); NTFS SetLength + // always allocates, so there is nothing to verify on Windows. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("Sparse spares are not observable on Windows; SetLength allocates on NTFS."); + } + + // Capacity well above a filesystem block so sparse (~0 reserved) vs full (~capacity reserved) + // is unambiguous. + const long capacity = QwpMmapSegment.HeaderSize + 4L * 1024 * 1024; + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: capacity); + using var mgr = new QwpSegmentManager(ring, long.MaxValue); + mgr.Start(); + + await WaitFor(() => mgr.SparesInstalled >= 1, TimeSpan.FromSeconds(5)); + + var spares = Directory.GetFiles( + _root, QwpSegmentRing.SparePrefix + "*" + QwpSegmentRing.SpareSuffix); + Assert.That(spares, Has.Length.EqualTo(1), "manager installs exactly one hot spare"); + + var allocated = GetAllocatedBytes(spares[0]); + Assert.That(allocated, Is.GreaterThanOrEqualTo(capacity / 2), + $"hot spare must be block-reserved, not sparse (allocated={allocated}, capacity={capacity})"); + } + + [Test] + public async Task DisksCap_RefusesNewSpareUntilTrim() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + using var mgr = new QwpSegmentManager(ring, maxTotalBytes: QwpMmapSegment.HeaderSize + 64); + mgr.Start(); + + await WaitFor(() => mgr.SparesInstalled >= 1, TimeSpan.FromSeconds(2)); + + // Producer adopts the spare; ring now holds one segment of 64 bytes — at the cap. + Assert.That(ring.TryAppend(new byte[24]), Is.True); + Assert.That(ring.TryAppend(new byte[24]), Is.True); + + // Cap reached. The next call would need a rotation. Wake manager — it must NOT install a + // second spare (committed=64 + capacity=64 > cap=64). + var sparesBefore = mgr.SparesInstalled; + Assert.That(ring.TryAppend(new byte[24]), Is.False); + await Task.Delay(100); + Assert.That(mgr.SparesInstalled, Is.EqualTo(sparesBefore), "no spare while cap-bound"); + + // Acknowledge fully covers segment 0 → manager trims → cap frees → spare installable. + ring.Acknowledge(1L); + await WaitFor(() => mgr.SparesInstalled > sparesBefore, TimeSpan.FromSeconds(2)); + Assert.That(mgr.CommittedBytes, Is.EqualTo((long)QwpMmapSegment.HeaderSize + 64), + "after trim+spare-install, committed back at one segment"); + } + + [Test] + public async Task Trim_RemovesAckedSegments_AndDecrementsCommittedBytes() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + using var mgr = new QwpSegmentManager(ring, maxTotalBytes: 1024); + mgr.Start(); + + // Fill three segments worth. + for (var i = 0; i < 6; i++) + { + await WaitFor(() => ring.TryAppend(new byte[24]), TimeSpan.FromSeconds(2)); + } + + Assert.That(ring.SealedSegmentCount, Is.GreaterThanOrEqualTo(2)); + + ring.Acknowledge(99L); + // Drain leaves only the active segment + at most one installed hot spare on disk; the + // manager reconciles _committedBytes to that. Don't capture a "before" snapshot — the + // spare-install timing races with capture and produces flaky deltas across machines. + await WaitFor( + () => ring.SealedSegmentCount == 0 && mgr.CommittedBytes <= 2 * ring.SegmentCapacity, + TimeSpan.FromSeconds(2)); + } + + [Test] + public async Task Dispose_ShutsDownCleanly_EvenWhenIdle() + { + var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + var mgr = new QwpSegmentManager(ring, long.MaxValue); + mgr.Start(); + + await WaitFor(() => mgr.SparesInstalled >= 1, TimeSpan.FromSeconds(2)); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + mgr.Dispose(); + sw.Stop(); + + Assert.That(sw.Elapsed, Is.LessThan(TimeSpan.FromSeconds(2)), "dispose should not block past the heartbeat"); + ring.Dispose(); + } + + [Test] + public async Task Wake_DrivesProvisioning_Promptly() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + using var mgr = new QwpSegmentManager(ring, long.MaxValue, heartbeatInterval: TimeSpan.FromSeconds(30)); + mgr.Start(); + + // Sample BEFORE consuming the startup spare — TryAllocateNewActive can wake the manager + // mid-append and bump SparesInstalled, racing with a post-append sample. + await WaitFor(() => mgr.SparesInstalled >= 1, TimeSpan.FromSeconds(5)); + var sparesBefore = mgr.SparesInstalled; + Assert.That(ring.TryAppend(new byte[24]), Is.True); + Assert.That(ring.TryAppend(new byte[24]), Is.True); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + await WaitFor(() => mgr.SparesInstalled > sparesBefore, TimeSpan.FromSeconds(5)); + sw.Stop(); + + Assert.That(sw.Elapsed, Is.LessThan(mgr.HeartbeatInterval), + "spare arrives via producer wake, not heartbeat tick"); + } + + [Test] + public async Task ConcurrentRotateAndTrim_NoCorruption() + { + // Producer hammers TryAppend (lots of rotations); receiver acks aggressively. Manager must + // service spare provisioning + trim concurrently without corrupting the ring. + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 256); + using var mgr = new QwpSegmentManager(ring, maxTotalBytes: 4 * 1024); + mgr.Start(); + + var stop = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + long appended = 0; + + var producer = Task.Run(() => + { + var payload = new byte[80]; + while (!stop.IsCancellationRequested) + { + if (ring.TryAppend(payload)) + { + Interlocked.Increment(ref appended); + } + else + { + Thread.SpinWait(10); + } + } + }); + + var acker = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + var hwm = ring.PublishedFsn; + if (hwm >= 0) + { + ring.Acknowledge(hwm); + } + Thread.Sleep(1); + } + }); + + await Task.WhenAll(producer, acker); + + Assert.That(Interlocked.Read(ref appended), Is.GreaterThan(0)); + // Final invariant: published = appended - 1 (assuming 0-based FSN), acked ≤ published. + Assert.That(ring.PublishedFsn, Is.EqualTo(Interlocked.Read(ref appended) - 1)); + Assert.That(ring.AckedFsn, Is.LessThanOrEqualTo(ring.PublishedFsn)); + } + + // On-disk bytes actually reserved for the file (st_blocks), as reported by `du -k`. A sparse file + // reports ~0 regardless of its logical length; a block-reserved file reports ~its length. + private static long GetAllocatedBytes(string path) + { + var psi = new System.Diagnostics.ProcessStartInfo("du") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("-k"); + psi.ArgumentList.Add(path); + + using var proc = System.Diagnostics.Process.Start(psi)!; + var stdout = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + // `du -k` emits "\t"; the first whitespace-delimited token is 1024-byte blocks. + var token = stdout.Split( + new[] { '\t', ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)[0]; + return long.Parse(token, System.Globalization.CultureInfo.InvariantCulture) * 1024; + } + + private static async Task WaitFor(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(10); + } + + Assert.Fail($"condition not satisfied within {timeout}"); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentRingTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentRingTests.cs new file mode 100644 index 00000000..bed6e1ab --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentRingTests.cs @@ -0,0 +1,339 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Runtime.CompilerServices; +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpSegmentRingTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-ring-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Test] + public void Open_FreshDirectory_StartsEmpty() + { + using var ring = QwpSegmentRing.Open(_root); + Assert.That(ring.SegmentCount, Is.Zero); + Assert.That(ring.NextFsn, Is.Zero); + Assert.That(ring.OldestFsn, Is.Zero); + } + + [Test] + public void Append_LargerThanSegment_Throws() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + Assert.Throws(() => ring.TryAppend(new byte[1024])); + } + + [Test] + public void Append_FillsSegmentThenRotates() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + + // 64-byte segment fits two ~24-byte envelopes (8 header + 24 body) before rotating. + Assert.That(ring.TryAppend(new byte[24]), Is.True); + Assert.That(ring.TryAppend(new byte[24]), Is.True); + // Next append rotates. + Assert.That(ring.TryAppend(new byte[24]), Is.True); + + Assert.That(ring.SegmentCount, Is.EqualTo(2)); + Assert.That(ring.NextFsn, Is.EqualTo(3L)); + } + + [Test] + public void TryReadFrame_AcrossSegments_RoundTrips() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + + var bodies = new[] + { + new byte[] { 1, 1, 1 }, + new byte[] { 2, 2, 2, 2 }, + new byte[] { 3, 3 }, + new byte[] { 4, 4, 4, 4, 4 }, + new byte[] { 5 }, + }; + + foreach (var b in bodies) + { + Assert.That(ring.TryAppend(b), Is.True); + } + + var dest = new byte[64]; + for (var i = 0; i < bodies.Length; i++) + { + var n = ring.TryReadFrame(i, dest); + Assert.That(n, Is.EqualTo(bodies[i].Length), $"frame {i}"); + Assert.That(dest.AsSpan(0, n).ToArray(), Is.EqualTo(bodies[i])); + } + } + + [Test] + public void TryReadFrame_OutOfRange_ReturnsMinusOne() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + ring.TryAppend(new byte[8]); + + var dest = new byte[64]; + Assert.That(ring.TryReadFrame(-1, dest), Is.EqualTo(-1)); + Assert.That(ring.TryReadFrame(99, dest), Is.EqualTo(-1)); + } + + [Test] + public void Acknowledge_FollowedByDrainTrimmable_PrecisePerSegment() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + + // Each envelope is 8 (header) + 24 (body) = 32 bytes. Two fit per 64-byte segment. + for (var i = 0; i < 6; i++) + { + ring.TryAppend(new byte[24]); + } + + Assert.That(ring.SegmentCount, Is.EqualTo(3)); + Assert.That(ring.OldestFsn, Is.EqualTo(0L)); + // Segment 0: FSNs 0,1. Segment 1: FSNs 2,3. Segment 2 (active): FSNs 4,5. + + // Watermark 0 → segment 0 has last FSN 1 > 0, not fully covered. + ring.Acknowledge(0L); + Assert.That(ring.DrainTrimmable(), Is.Null); + + // Watermark 1 → segment 0 fully covered, drains 1. + ring.Acknowledge(1L); + var drained = ring.DrainTrimmable(); + Assert.That(drained, Is.Not.Null); + Assert.That(drained!.Count, Is.EqualTo(1)); + foreach (var s in drained) s.Dispose(); + Assert.That(ring.SegmentCount, Is.EqualTo(2)); + Assert.That(ring.OldestFsn, Is.EqualTo(2L)); + + // Watermark 99 → would cover segments 1 and active, but active is never drained. + ring.Acknowledge(99L); + drained = ring.DrainTrimmable(); + Assert.That(drained, Is.Not.Null); + Assert.That(drained!.Count, Is.EqualTo(1)); + foreach (var s in drained) s.Dispose(); + Assert.That(ring.SegmentCount, Is.EqualTo(1)); + Assert.That(ring.OldestFsn, Is.EqualTo(4L)); + } + + [Test] + public void Acknowledge_IsMonotonic() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + ring.TryAppend(new byte[24]); + ring.TryAppend(new byte[24]); + + ring.Acknowledge(1L); + Assert.That(ring.AckedFsn, Is.EqualTo(1L)); + + // Older ACK is silently absorbed. + ring.Acknowledge(0L); + Assert.That(ring.AckedFsn, Is.EqualTo(1L)); + } + + [Test] + public void NeedsHotSpare_FreshThenInstall() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + Assert.That(ring.NeedsHotSpare(), Is.True, "fresh ring needs initial active"); + + // Initial append creates the active segment; spare not yet needed (under high-water). + ring.TryAppend(new byte[8]); + Assert.That(ring.NeedsHotSpare(), Is.False); + + // Cross 75% — needs spare. + ring.TryAppend(new byte[24]); + Assert.That(ring.NeedsHotSpare(), Is.True); + + // Install one — no longer needs. + var spare = Path.Combine(_root, "manual-spare.tmp"); + File.WriteAllBytes(spare, new byte[64]); + Assert.That(ring.InstallHotSpare(spare), Is.True); + Assert.That(ring.NeedsHotSpare(), Is.False); + } + + [Test] + public void InstallHotSpare_TwiceWithoutConsumption_RejectsSecond() + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + var spare1 = Path.Combine(_root, "spare1.tmp"); + File.WriteAllBytes(spare1, new byte[64]); + Assert.That(ring.InstallHotSpare(spare1), Is.True); + + var spare2 = Path.Combine(_root, "spare2.tmp"); + File.WriteAllBytes(spare2, new byte[64]); + Assert.That(ring.InstallHotSpare(spare2), Is.False); + } + + [Test] + public void Reopen_AfterAppends_RecoversWriteHead() + { + for (var iter = 0; iter < 2; iter++) + { + using var ring = QwpSegmentRing.Open(_root, segmentCapacity: QwpMmapSegment.HeaderSize + 64); + + if (iter == 0) + { + ring.TryAppend(new byte[24]); + ring.TryAppend(new byte[24]); + ring.TryAppend(new byte[24]); // rotation, FSN 2 in segment 1. + } + else + { + Assert.That(ring.SegmentCount, Is.EqualTo(2)); + Assert.That(ring.NextFsn, Is.EqualTo(3L)); + Assert.That(ring.OldestFsn, Is.Zero); + } + } + } + + [Test] + public void OpenMemoryBacked_AppendsRotateAndRoundTrip() + { + using var ring = QwpSegmentRing.OpenMemoryBacked(segmentCapacity: 64); + Assert.That(ring.IsMemoryBacked, Is.True); + + // Each envelope is 8 (header) + 24 (body) = 32 bytes; two fit per 64-byte RAM segment. + var bodies = new byte[5][]; + for (var i = 0; i < bodies.Length; i++) + { + bodies[i] = new byte[24]; + Array.Fill(bodies[i], (byte)(i + 1)); + Assert.That(ring.TryAppend(bodies[i]), Is.True); + } + + Assert.That(ring.SegmentCount, Is.EqualTo(3)); + Assert.That(ring.NextFsn, Is.EqualTo(5L)); + + var dest = new byte[64]; + for (var i = 0; i < bodies.Length; i++) + { + var n = ring.TryReadFrame(i, dest); + Assert.That(n, Is.EqualTo(24), $"frame {i}"); + Assert.That(dest.AsSpan(0, n).ToArray(), Is.EqualTo(bodies[i])); + } + } + + [Test] + public void OpenMemoryBacked_SetMaxTotalBytes_RejectsAppendBeyondCap() + { + using var ring = QwpSegmentRing.OpenMemoryBacked(segmentCapacity: 64); + ring.SetMaxTotalBytes(128); // room for exactly two 64-byte segments + + // Two 32-byte envelopes per segment; four appends fill both segments. + for (var i = 0; i < 4; i++) + { + Assert.That(ring.TryAppend(new byte[24]), Is.True, $"append {i}"); + } + + // A fifth append would need a third segment, exceeding the 128-byte cap. + Assert.That(ring.TryAppend(new byte[24]), Is.False, "append past the cap must be refused"); + Assert.That(ring.SegmentCount, Is.EqualTo(2)); + Assert.That(ring.NextFsn, Is.EqualTo(4L), "the refused append must not advance the FSN"); + } + + [Test] + public void OpenMemoryBacked_DrainTrimmable_FreesSegmentsAndUnblocksAppend() + { + using var ring = QwpSegmentRing.OpenMemoryBacked(segmentCapacity: 64); + ring.SetMaxTotalBytes(128); + + for (var i = 0; i < 4; i++) + { + Assert.That(ring.TryAppend(new byte[24]), Is.True); + } + Assert.That(ring.TryAppend(new byte[24]), Is.False, "cap reached"); + + // ACK the first segment's FSNs (0,1), trim it, and free its native memory. + ring.Acknowledge(1L); + var drained = ring.DrainTrimmable(); + Assert.That(drained, Is.Not.Null); + Assert.That(drained!.Count, Is.EqualTo(1)); + foreach (var s in drained) s.Dispose(); + + Assert.That(ring.SegmentCount, Is.EqualTo(1)); + Assert.That(ring.TryAppend(new byte[24]), Is.True, "trimming a segment frees cap budget"); + } + + [Test] + [NonParallelizable] + public void MemoryRingDroppedWithoutDispose_FinalizerFreesSegmentNativeMemory() + { + Collect(); + var baseline = Interlocked.Read(ref QwpMemorySegment.LiveNativeBytes); + + AllocateMemoryRingAndDrop(); + + // The ring is now unreachable and was never Disposed. Its ~QwpSegmentRing finalizer must free + // every RAM-backed segment it still holds; otherwise their native buffers survive GC and + // LiveNativeBytes stays elevated forever — the leak. + Collect(); + var residual = Interlocked.Read(ref QwpMemorySegment.LiveNativeBytes) - baseline; + + Assert.That(residual, Is.Zero, + $"{residual} byte(s) of native segment memory survived GC — a memory-backed QwpSegmentRing " + + "dropped without Dispose must free its segments from its finalizer"); + } + + // Kept out of the test body so no local on the test's frame can root the ring past the drop. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AllocateMemoryRingAndDrop() + { + // Small segments + many appends force several rotations, so the ring ends up holding an + // active segment plus a handful of sealed ones — all of which the finalizer must free. + var ring = QwpSegmentRing.OpenMemoryBacked(segmentCapacity: 4096); + var frame = new byte[2000]; + for (var i = 0; i < 16; i++) + { + ring.TryAppend(frame); + } + // Deliberately no Dispose: simulate the holder GC'd without teardown. + } + + private static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpSenderErrorDispatcherTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpSenderErrorDispatcherTests.cs new file mode 100644 index 00000000..83f518d2 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSenderErrorDispatcherTests.cs @@ -0,0 +1,112 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpSenderErrorDispatcherTests +{ + [Test] + public async Task Overflow_DropsOldest_KeepsNewest() + { + var seen = new List(); + var gate = new ManualResetEventSlim(); + var release = new ManualResetEventSlim(); + + using var dispatcher = new QwpSenderErrorDispatcher(err => + { + // Block the first delivery so the channel fills up before the loop drains it. + if (seen.Count == 0) + { + gate.Set(); + release.Wait(); + } + lock (seen) seen.Add(err.FromFsn); + }, capacity: 3); + + // Prime the dispatcher loop with one item, then block its handler so subsequent Offers + // pile up in the channel. + dispatcher.Offer(MakeError(0)); + gate.Wait(); + + // With the handler stuck and the reader paused, capacity is 3. Push 6 items; the + // oldest 3 must be dropped, leaving FromFsn=3,4,5 in the channel. + for (var i = 1; i <= 6; i++) dispatcher.Offer(MakeError(i)); + + release.Set(); + + // Wait until everything that could be delivered has been delivered. + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + lock (seen) + { + if (seen.Count >= 4 && dispatcher.DroppedNotifications >= 3) break; + } + await Task.Delay(20); + } + + lock (seen) + { + // First item was item 0 (it primed the handler). After it released, the channel held + // the newest 3 (4, 5, 6); the oldest items (1, 2, 3) were evicted. + Assert.That(seen, Is.EqualTo(new long[] { 0, 4, 5, 6 })); + } + Assert.That(dispatcher.DroppedNotifications, Is.EqualTo(3), + "expected exactly 3 drops (items 1, 2, 3 evicted by DropOldest)"); + Assert.That(dispatcher.TotalDelivered, Is.EqualTo(4)); + } + + [Test] + public void Offer_AfterDispose_ReturnsFalse_NoDrop() + { + using var dispatcher = new QwpSenderErrorDispatcher(_ => { }, capacity: 4); + dispatcher.Dispose(); + + Assert.That(dispatcher.Offer(MakeError(0)), Is.False); + Assert.That(dispatcher.DroppedNotifications, Is.EqualTo(0)); + } + + [Test] + public void Ctor_RejectsZeroCapacity() + { + Assert.Catch(() => new QwpSenderErrorDispatcher(_ => { }, capacity: 0)); + } + + private static SenderError MakeError(long fsn) => new( + category: SenderErrorCategory.ProtocolViolation, + appliedPolicy: SenderErrorPolicy.DropAndContinue, + serverStatusByte: SenderError.NoStatusByte, + serverMessage: null, + messageSequence: SenderError.NoMessageSequence, + fromFsn: fsn, + toFsn: fsn, + tableName: null, + detectedAtUtc: DateTime.UtcNow); +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpSlotLockTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpSlotLockTests.cs new file mode 100644 index 00000000..0e6d6c77 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSlotLockTests.cs @@ -0,0 +1,204 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpSlotLockTests +{ + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "qwp-slotlock-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Test] + public void Acquire_FreshDirectory_Succeeds() + { + using var slotLock = QwpSlotLock.Acquire(_root); + + Assert.That(slotLock.SlotDirectory, Is.EqualTo(_root)); + Assert.That(slotLock.LockFilePath, Is.EqualTo(Path.Combine(_root, ".lock"))); + Assert.That(File.Exists(slotLock.LockFilePath), Is.True); + } + + [Test] + public void Acquire_CreatesSlotDirectoryIfMissing() + { + Assert.That(Directory.Exists(_root), Is.False); + + using var slotLock = QwpSlotLock.Acquire(_root); + + Assert.That(Directory.Exists(_root), Is.True); + } + + [Test] + public void Acquire_AlreadyHeldInProcess_ThrowsIngressError() + { + using var first = QwpSlotLock.Acquire(_root); + + var ex = Assert.Catch(() => QwpSlotLock.Acquire(_root)); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ConfigError)); + Assert.That(ex.Message, Does.Contain(_root)); + } + + [Test] + public void TryAcquire_FreshDirectory_ReturnsLock() + { + using var slotLock = QwpSlotLock.TryAcquire(_root); + + Assert.That(slotLock, Is.Not.Null); + Assert.That(File.Exists(slotLock!.LockFilePath), Is.True); + } + + [Test] + public void TryAcquire_AlreadyHeld_ReturnsNull() + { + using var first = QwpSlotLock.Acquire(_root); + + var second = QwpSlotLock.TryAcquire(_root); + Assert.That(second, Is.Null); + } + + [Test] + public void Dispose_ReleasesLock_AllowsReacquisition() + { + var first = QwpSlotLock.Acquire(_root); + first.Dispose(); + + // After disposal a second acquisition must succeed. + using var second = QwpSlotLock.Acquire(_root); + Assert.That(second.SlotDirectory, Is.EqualTo(_root)); + } + + [Test] + public void Dispose_Idempotent() + { + var slotLock = QwpSlotLock.Acquire(_root); + slotLock.Dispose(); + Assert.DoesNotThrow(() => slotLock.Dispose()); + } + + [Test] + public void Acquire_DistinctSlots_AreIndependent() + { + var slotA = Path.Combine(_root, "sender-a"); + var slotB = Path.Combine(_root, "sender-b"); + + using var lockA = QwpSlotLock.Acquire(slotA); + using var lockB = QwpSlotLock.Acquire(slotB); + + Assert.That(lockA.SlotDirectory, Is.EqualTo(slotA)); + Assert.That(lockB.SlotDirectory, Is.EqualTo(slotB)); + } + + [Test] + public void TryRunUnderLock_BeforeDispose_RunsAction() + { + using var slotLock = QwpSlotLock.Acquire(_root); + var observedDir = string.Empty; + + var ran = slotLock.TryRunUnderLock(dir => observedDir = dir); + + Assert.That(ran, Is.True); + Assert.That(observedDir, Is.EqualTo(_root)); + } + + [Test] + public void TryRunUnderLock_AfterDispose_SkipsAction() + { + var slotLock = QwpSlotLock.Acquire(_root); + slotLock.Dispose(); + var actionInvoked = false; + + var ran = slotLock.TryRunUnderLock(_ => actionInvoked = true); + + Assert.That(ran, Is.False); + Assert.That(actionInvoked, Is.False); + } + + [Test] + public void TryRunUnderLock_SerializesAgainstDispose() + { + var slotLock = QwpSlotLock.Acquire(_root); + var enteredAction = new ManualResetEventSlim(); + var releaseAction = new ManualResetEventSlim(); + var actionCompleted = false; + + var holder = Task.Run(() => + { + slotLock.TryRunUnderLock(_ => + { + enteredAction.Set(); + releaseAction.Wait(); + actionCompleted = true; + }); + }); + + Assert.That(enteredAction.Wait(TimeSpan.FromSeconds(2)), Is.True); + + var disposer = Task.Run(() => slotLock.Dispose()); + + Assert.That(disposer.Wait(TimeSpan.FromMilliseconds(100)), Is.False, + "Dispose must not complete while a TryRunUnderLock action is in flight"); + + releaseAction.Set(); + Assert.That(Task.WhenAll(holder, disposer).Wait(TimeSpan.FromSeconds(5)), Is.True); + Assert.That(actionCompleted, Is.True); + Assert.That(slotLock.TryRunUnderLock(_ => { }), Is.False); + } + + [Test] + public void RefreshHeartbeat_AdvancesMtime_OnAlreadyEmptyFile() + { + using var slotLock = QwpSlotLock.Acquire(_root); + var heartbeatPath = Path.Combine(_root, ".heartbeat"); + Assert.That(File.Exists(heartbeatPath), Is.True); + + var stale = DateTime.UtcNow.AddMinutes(-5); + File.SetLastWriteTimeUtc(heartbeatPath, stale); + Assert.That(File.GetLastWriteTimeUtc(heartbeatPath), Is.EqualTo(stale).Within(TimeSpan.FromSeconds(1))); + + slotLock.RefreshHeartbeat(); + + var after = File.GetLastWriteTimeUtc(heartbeatPath); + Assert.That(after, Is.GreaterThan(stale.AddMinutes(1))); + } +} diff --git a/src/net-questdb-client-tests/Qwp/Sf/QwpTrackedCursorTransportTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpTrackedCursorTransportTests.cs new file mode 100644 index 00000000..80dfcd6c --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpTrackedCursorTransportTests.cs @@ -0,0 +1,190 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using NUnit.Framework; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class QwpTrackedCursorTransportTests +{ + [Test] + public void RoleReject_WithZoneHeader_RecordsZoneBeforeRoleReject() + { + // Spec: zone is observed independently of state; once observed it persists for the host's + // lifetime in this client. Without RecordZone on the 421 path, an ingress sender that later + // sets a clientZone would tier hosts incorrectly. + var hosts = new[] { "h1:9000", "h2:9000" }; + // targetIsPrimary=true folds every zone into Same (ingress-default behaviour); use + // targetIsPrimary=false here so RecordZone actually distinguishes Same vs Other. + var tracker = new QwpHostHealthTracker(hosts, clientZone: "us-east-1", targetIsPrimary: false); + var stub = new RejectingStub("REPLICA", "us-east-1"); + var tracked = new QwpTrackedCursorTransport(stub, tracker, hostIndex: 0); + + Assert.ThrowsAsync( + async () => await tracked.ConnectAsync(CancellationToken.None)); + + Assert.That(tracker.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same), + "host returning X-QuestDB-Zone matching clientZone must tier as Same"); + Assert.That(tracker.GetState(0), Is.EqualTo(QwpHostState.TopologyReject), + "REPLICA when targetIsPrimary records a non-transient role reject"); + } + + [Test] + public void RoleReject_WithoutZoneHeader_DoesNotTouchZoneTier() + { + var hosts = new[] { "h1:9000" }; + // targetIsPrimary=true folds every zone into Same (ingress-default behaviour); use + // targetIsPrimary=false here so RecordZone actually distinguishes Same vs Other. + var tracker = new QwpHostHealthTracker(hosts, clientZone: "us-east-1", targetIsPrimary: false); + var stub = new RejectingStub("REPLICA", zone: null); + var tracked = new QwpTrackedCursorTransport(stub, tracker, hostIndex: 0); + + Assert.ThrowsAsync( + async () => await tracked.ConnectAsync(CancellationToken.None)); + + Assert.That(tracker.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Unknown)); + } + + [Test] + public void RoleReject_CrossZoneRemoteHost_TiersAsOther() + { + var hosts = new[] { "h1:9000" }; + // targetIsPrimary=true folds every zone into Same (ingress-default behaviour); use + // targetIsPrimary=false here so RecordZone actually distinguishes Same vs Other. + var tracker = new QwpHostHealthTracker(hosts, clientZone: "us-east-1", targetIsPrimary: false); + var stub = new RejectingStub("REPLICA", "eu-west-2"); + var tracked = new QwpTrackedCursorTransport(stub, tracker, hostIndex: 0); + + Assert.ThrowsAsync( + async () => await tracked.ConnectAsync(CancellationToken.None)); + + Assert.That(tracker.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Other)); + } + + [Test] + public void Success_WithZoneHeader_RecordsZoneOnConnect() + { + // The same-zone vs other-zone tiering must engage among *healthy* hosts, not just hosts + // that role-reject. The success path therefore has to record the negotiated zone too. + var hosts = new[] { "h1:9000", "h2:9000" }; + // targetIsPrimary=false so RecordZone actually distinguishes Same vs Other (ingress folds + // every zone into Same when targetIsPrimary). + var tracker = new QwpHostHealthTracker(hosts, clientZone: "us-east-1", targetIsPrimary: false); + var stub = new ConnectingStub("us-east-1"); + var tracked = new QwpTrackedCursorTransport(stub, tracker, hostIndex: 0); + + Assert.DoesNotThrowAsync(async () => await tracked.ConnectAsync(CancellationToken.None)); + + Assert.That(tracker.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Same), + "a healthy host whose X-QuestDB-Zone matches clientZone must tier as Same"); + Assert.That(tracker.GetState(0), Is.EqualTo(QwpHostState.Healthy)); + } + + [Test] + public void Success_CrossZoneHost_TiersAsOther() + { + var hosts = new[] { "h1:9000" }; + var tracker = new QwpHostHealthTracker(hosts, clientZone: "us-east-1", targetIsPrimary: false); + var stub = new ConnectingStub("eu-west-2"); + var tracked = new QwpTrackedCursorTransport(stub, tracker, hostIndex: 0); + + Assert.DoesNotThrowAsync(async () => await tracked.ConnectAsync(CancellationToken.None)); + + Assert.That(tracker.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Other)); + Assert.That(tracker.GetState(0), Is.EqualTo(QwpHostState.Healthy)); + } + + [Test] + public void Success_WithoutZoneHeader_DoesNotTouchZoneTier() + { + var hosts = new[] { "h1:9000" }; + var tracker = new QwpHostHealthTracker(hosts, clientZone: "us-east-1", targetIsPrimary: false); + var stub = new ConnectingStub(zone: null); + var tracked = new QwpTrackedCursorTransport(stub, tracker, hostIndex: 0); + + Assert.DoesNotThrowAsync(async () => await tracked.ConnectAsync(CancellationToken.None)); + + Assert.That(tracker.GetZoneTier(0), Is.EqualTo(QwpZoneTier.Unknown), + "a server that advertises no zone must leave the tier untouched"); + Assert.That(tracker.GetState(0), Is.EqualTo(QwpHostState.Healthy)); + } + + private sealed class RejectingStub : IQwpCursorTransport + { + private readonly string _role; + private readonly string? _zone; + + public RejectingStub(string role, string? zone) + { + _role = role; + _zone = zone; + } + + public (string Host, int Port)? Endpoint => ("h", 9000); + + public Task ConnectAsync(CancellationToken cancellationToken) => + throw new QwpIngressRoleRejectedException(_role, new Uri("ws://h:9000/write/v4"), _zone); + + public Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken) => + Task.FromResult(0); + + public Task CloseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() { } + } + + private sealed class ConnectingStub : IQwpCursorTransport + { + public ConnectingStub(string? zone) + { + NegotiatedZone = zone; + } + + public (string Host, int Port)? Endpoint => ("h", 9000); + + public string? NegotiatedZone { get; } + + public Task ConnectAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken) => + Task.FromResult(0); + + public Task CloseAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() { } + } +} + +#endif diff --git a/src/net-questdb-client-tests/Qwp/Sf/SfCleanupTests.cs b/src/net-questdb-client-tests/Qwp/Sf/SfCleanupTests.cs new file mode 100644 index 00000000..08c17187 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/SfCleanupTests.cs @@ -0,0 +1,102 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Qwp.Sf; + +namespace net_questdb_client_tests.Qwp.Sf; + +[TestFixture] +public class SfCleanupTests +{ + [Test] + public void Run_PlantedIOException_Swallowed() + { + Assert.DoesNotThrow(() => SfCleanup.Run(() => throw new IOException("expected"))); + } + + [Test] + public void Run_PlantedNullReferenceException_Escapes() + { + Assert.Throws( + () => SfCleanup.Run(() => throw new NullReferenceException("planted"))); + } + + [Test] + public void Run_AggregateException_OfExpectedOnly_Swallowed() + { + Assert.DoesNotThrow(() => SfCleanup.Run(() => + throw new AggregateException(new IOException("a"), new ObjectDisposedException("b")))); + } + + [Test] + public void Run_AggregateException_WithUnexpectedInner_Escapes() + { + // The recursion guarantee: a wrapped real bug must not be silently swallowed. + Assert.Throws(() => SfCleanup.Run(() => + throw new AggregateException(new IOException("a"), new NullReferenceException("planted")))); + } + + [Test] + public void Run_NestedAggregateException_RecursesCorrectly() + { + Assert.Throws(() => SfCleanup.Run(() => + throw new AggregateException( + new AggregateException(new InvalidOperationException("planted"))))); + } + + [Test] + public void Dispose_NullDisposable_NoOp() + { + Assert.DoesNotThrow(() => SfCleanup.Dispose(null)); + } + + [Test] + public void Dispose_PlantedNullReference_Escapes() + { + var disposable = new ThrowingDisposable(new NullReferenceException("planted")); + Assert.Throws(() => SfCleanup.Dispose(disposable)); + } + + [Test] + public void Dispose_PlantedIOException_Swallowed() + { + var disposable = new ThrowingDisposable(new IOException("expected")); + Assert.DoesNotThrow(() => SfCleanup.Dispose(disposable)); + } + + [Test] + public void DeleteFile_MissingPath_NoOp() + { + var path = Path.Combine(Path.GetTempPath(), "sf-cleanup-missing-" + Guid.NewGuid().ToString("N")); + Assert.DoesNotThrow(() => SfCleanup.DeleteFile(path)); + } + + private sealed class ThrowingDisposable : IDisposable + { + private readonly Exception _ex; + public ThrowingDisposable(Exception ex) { _ex = ex; } + public void Dispose() => throw _ex; + } +} diff --git a/src/net-questdb-client-tests/QwpWsFuzzUnitTests.cs b/src/net-questdb-client-tests/QwpWsFuzzUnitTests.cs new file mode 100644 index 00000000..a851b05a --- /dev/null +++ b/src/net-questdb-client-tests/QwpWsFuzzUnitTests.cs @@ -0,0 +1,139 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Net.Sockets; +using NUnit.Framework; + +namespace net_questdb_client_tests; + +/// +/// Offline unit tests for the QWP ingress fuzz harness's error classification; port of +/// test_qwp_ws_fuzz_unit.py. No QuestDB fixture — drives the classifiers directly so the +/// transient-vs-fatal distinction is verified deterministically, not only via a bounce race. +/// +[TestFixture] +public class QwpWsFuzzUnitTests +{ + [Test] + public void HttpRequestException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new HttpRequestException("Connection refused")), Is.True); + + [Test] + public void SocketException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new SocketException()), Is.True); + + [Test] + public void IOException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new IOException("Connection reset by peer")), Is.True); + + [Test] + public void TimeoutException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new TimeoutException("timed out")), Is.True); + + [Test] + public void TaskCanceledException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new TaskCanceledException()), Is.True); + + [Test] + public void ConnectionResetMessage_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new Exception("Connection reset by peer")), Is.True); + + [Test] + public void ConnectionRefusedMessage_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new Exception("connection refused")), Is.True); + + [Test] + public void BrokenPipeMessage_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new Exception("Broken pipe")), Is.True); + + // A wrapping layer can hide the type but keep a recognisable message. + [Test] + public void WrappedTimedOutMessage_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new Exception("Read timed out")), Is.True); + + [Test] + public void InnerSocketException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new Exception("wrapper", new SocketException())), Is.True); + + [Test] + public void ArgumentException_IsNotTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new ArgumentException("bad column type")), Is.False); + + [Test] + public void InvalidOperationException_IsNotTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.IsTransientNetworkError( + new InvalidOperationException("logic error")), Is.False); + + [Test] + public void AlterError_ConnectionReset_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.ClassifyAlterError( + new IOException("Connection reset by peer")), + Is.EqualTo(QuestDbWebSocketIngestFuzzTests.AlterErrorClass.Transient)); + + [Test] + public void AlterError_Timeout_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.ClassifyAlterError( + new TimeoutException("timed out")), + Is.EqualTo(QuestDbWebSocketIngestFuzzTests.AlterErrorClass.Transient)); + + [Test] + public void AlterError_HttpRequestException_IsTransient() + => Assert.That(QuestDbWebSocketIngestFuzzTests.ClassifyAlterError( + new HttpRequestException("Connection refused")), + Is.EqualTo(QuestDbWebSocketIngestFuzzTests.AlterErrorClass.Transient)); + + [Test] + public void AlterError_TypeAlready_IsTolerated() + => Assert.That(QuestDbWebSocketIngestFuzzTests.ClassifyAlterError( + new Exception("type is already SYMBOL")), + Is.EqualTo(QuestDbWebSocketIngestFuzzTests.AlterErrorClass.Tolerated)); + + [Test] + public void AlterError_DesignatedTimestamp_IsTolerated() + => Assert.That(QuestDbWebSocketIngestFuzzTests.ClassifyAlterError( + new Exception("cannot change designated timestamp")), + Is.EqualTo(QuestDbWebSocketIngestFuzzTests.AlterErrorClass.Tolerated)); + + // A real programming error must surface, not be lost in the transient-tolerance bucket. + [Test] + public void AlterError_RealBug_IsFatal() + => Assert.That(QuestDbWebSocketIngestFuzzTests.ClassifyAlterError( + new ArgumentException("this would be a real bug")), + Is.EqualTo(QuestDbWebSocketIngestFuzzTests.AlterErrorClass.Fatal)); +} + +#endif diff --git a/src/net-questdb-client-tests/SenderFromEnvTests.cs b/src/net-questdb-client-tests/SenderFromEnvTests.cs new file mode 100644 index 00000000..de9e10e3 --- /dev/null +++ b/src/net-questdb-client-tests/SenderFromEnvTests.cs @@ -0,0 +1,92 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace net_questdb_client_tests; + +[TestFixture] +[NonParallelizable] +public class SenderFromEnvTests +{ + private const string EnvVar = "QDB_CLIENT_CONF"; + private string? _saved; + + [SetUp] + public void SaveEnv() => _saved = Environment.GetEnvironmentVariable(EnvVar); + + [TearDown] + public void RestoreEnv() => Environment.SetEnvironmentVariable(EnvVar, _saved); + + [Test] + public void SenderFromEnv_BlankEnv_ThrowsConfigError() + { + Environment.SetEnvironmentVariable(EnvVar, null); + var ex = Assert.Throws(() => Sender.FromEnv()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ConfigError)); + Assert.That(ex.Message, Does.Contain(EnvVar)); + } + + [Test] + public void SenderFromEnv_WhitespaceEnv_ThrowsConfigError() + { + Environment.SetEnvironmentVariable(EnvVar, " "); + var ex = Assert.Throws(() => Sender.FromEnv()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ConfigError)); + } + + [Test] + public void SenderFromEnv_ValidConfig_ProducesSender() + { + Environment.SetEnvironmentVariable(EnvVar, "http::addr=localhost:9000;"); + using var sender = Sender.FromEnv(); + Assert.That(sender, Is.Not.Null); + } + + [Test] + public void SenderFromEnv_BadConfig_PropagatesParserError() + { + Environment.SetEnvironmentVariable(EnvVar, "no_scheme_separator"); + Assert.Throws(() => Sender.FromEnv()); + } + + [Test] + public void QueryClientFromEnv_BlankEnv_ThrowsConfigError() + { + Environment.SetEnvironmentVariable(EnvVar, null); + var ex = Assert.Throws(() => QueryClient.FromEnv()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ConfigError)); + Assert.That(ex.Message, Does.Contain(EnvVar)); + } + + [Test] + public void QueryClientFromEnv_NonWsScheme_RejectsAtFactory() + { + Environment.SetEnvironmentVariable(EnvVar, "http::addr=localhost:9000;"); + Assert.Throws(() => QueryClient.FromEnv()); + } +} diff --git a/src/net-questdb-client-tests/SenderOptionsTests.cs b/src/net-questdb-client-tests/SenderOptionsTests.cs index 92c68bb6..6d014465 100644 --- a/src/net-questdb-client-tests/SenderOptionsTests.cs +++ b/src/net-questdb-client-tests/SenderOptionsTests.cs @@ -26,6 +26,7 @@ using System.Diagnostics; using Microsoft.Extensions.Configuration; using NUnit.Framework; +using QuestDB.Enums; using QuestDB.Utils; namespace net_questdb_client_tests; @@ -59,20 +60,11 @@ public void CapitalCaseInValues() [Test] public void DuplicateKey() { - // duplicate keys are 'last writer wins' - Assert.That( - new SenderOptions("http::addr=localhost:9000;addr=localhost:9009;").addr, - Is.EqualTo("localhost:9009")); - } - - [Test] - public void KeyCannotStartWithNumber() - { - // invalid property - Assert.That( - () => new SenderOptions("https::123=456;"), - Throws.TypeOf().With.Message.Contains("Invalid property") - ); + // Multiple `addr=` keys accumulate as a multi-host failover list. `addr` returns the + // first (= primary attempt order) for back-compat; `addresses` exposes the full list. + var opts = new SenderOptions("http::addr=localhost:9000;addr=localhost:9009;"); + Assert.That(opts.addr, Is.EqualTo("localhost:9000")); + Assert.That(opts.addresses, Is.EqualTo(new[] { "localhost:9000", "localhost:9009" })); } [Test] @@ -80,17 +72,7 @@ public void DefaultConfig() { Assert.That( new SenderOptions("http::addr=localhost:9000;").ToString() - , Is.EqualTo("http::addr=localhost:9000;auth_timeout=15000;auto_flush=on;auto_flush_bytes=2147483647;auto_flush_interval=1000;auto_flush_rows=75000;gzip=False;init_buf_size=65536;max_buf_size=104857600;max_name_len=127;pool_timeout=120000;protocol_version=Auto;request_min_throughput=102400;request_timeout=10000;retry_timeout=10000;tls_verify=on;")); - } - - [Test] - public void InvalidProperty() - { - Assert.That( - () => new SenderOptions("http::asdada=localhost:9000;"), - Throws.TypeOf() - .With.Message.Contains("Invalid property") - ); + , Is.EqualTo("http::addr=localhost:9000;auth_timeout=15000;auto_flush=on;auto_flush_bytes=2147483647;auto_flush_interval=1000;auto_flush_rows=75000;convert_local_to_utc=off;gzip=False;init_buf_size=65536;max_buf_size=104857600;max_name_len=127;pool_timeout=120000;protocol_version=Auto;request_min_throughput=102400;request_timeout=30000;retry_timeout=10000;tls_verify=on;")); } [Test] @@ -112,7 +94,7 @@ public void UseOffInAutoFlushSettings() Assert.That(senderOptions.ToString(), Is.EqualTo( - "http::addr=localhost:9000;auth_timeout=15000;auto_flush=on;auto_flush_bytes=-1;auto_flush_interval=-1;auto_flush_rows=-1;gzip=False;init_buf_size=65536;max_buf_size=104857600;max_name_len=127;pool_timeout=120000;protocol_version=Auto;request_min_throughput=102400;request_timeout=10000;retry_timeout=10000;tls_verify=on;")); + "http::addr=localhost:9000;auth_timeout=15000;auto_flush=on;auto_flush_bytes=-1;auto_flush_interval=-1;auto_flush_rows=-1;convert_local_to_utc=off;gzip=False;init_buf_size=65536;max_buf_size=104857600;max_name_len=127;pool_timeout=120000;protocol_version=Auto;request_min_throughput=102400;request_timeout=30000;retry_timeout=10000;tls_verify=on;")); } [Test] @@ -142,4 +124,1187 @@ public void GzipInToString() var senderOptions = new SenderOptions("http::addr=localhost:9000;gzip=true;"); Assert.That(senderOptions.ToString(), Does.Contain("gzip=True")); } + + [Test] + public void ConvertLocalToUtcDefaultFalse() + { + Assert.That(new SenderOptions("http::addr=localhost:9000;").convert_local_to_utc, Is.False); + } + + [Test] + [TestCase("http::addr=localhost:9000;convert_local_to_utc=on;")] + [TestCase("tcp::addr=localhost:9009;convert_local_to_utc=on;")] + [TestCase("ws::addr=localhost:9000;convert_local_to_utc=on;")] + public void ConvertLocalToUtcAcceptedOnAllIngestSchemes(string confStr) + { + Assert.That(new SenderOptions(confStr).convert_local_to_utc, Is.True); + } + + [Test] + public void ConvertLocalToUtcOffParses() + { + Assert.That(new SenderOptions("http::addr=localhost:9000;convert_local_to_utc=off;").convert_local_to_utc, + Is.False); + } + + [Test] + public void ConvertLocalToUtcRoundTripsViaToString() + { + var original = new SenderOptions("http::addr=localhost:9000;convert_local_to_utc=on;"); + // Emit the on/off config token, not the CLR bool literal (which would round-trip but + // re-introduce true/false vocabulary into the connect string). + Assert.That(original.ToString(), Does.Contain("convert_local_to_utc=on")); + var roundTripped = new SenderOptions(original.ToString()); + Assert.That(roundTripped.convert_local_to_utc, Is.True); + } + + [Test] + public void ToString_OmitsSecretProperties() + { + var opts = new SenderOptions( + "https::addr=localhost:9000;username=alice;password=hunter2;tls_roots=/etc/ssl;tls_roots_password=ts3cret;"); + var serialised = opts.ToString(); + + Assert.That(serialised, Does.Not.Contain("hunter2")); + Assert.That(serialised, Does.Not.Contain("ts3cret")); + Assert.That(serialised, Does.Not.Contain("password")); + Assert.That(serialised, Does.Not.Contain("tls_roots_password")); + Assert.That(serialised, Does.Contain("username=alice")); + } + + [Test] + public void ToString_DoesNotEmitSecretKeyWhenAbsent() + { + var opts = new SenderOptions("http::addr=localhost:9000;"); + var serialised = opts.ToString(); + Assert.That(serialised, Does.Not.Contain("password")); + Assert.That(serialised, Does.Not.Contain("token")); + } + + [Test] + public void ToString_RoundTrips_WithProgrammaticConnectionListener() + { + var opts = new SenderOptions("ws::addr=localhost:9000;") + { + ConnectionListener = new NoopConnectionListener(), + }; + + var serialised = opts.ToString(); + Assert.That(serialised, Does.Not.Contain("ConnectionListener")); + Assert.DoesNotThrow(() => _ = new SenderOptions(serialised)); + } + + private sealed class NoopConnectionListener : QuestDB.Senders.ISenderConnectionListener + { + public void OnEvent(QuestDB.Senders.SenderConnectionEvent evt) + { + } + } + + [Test] + public void Format_OmitsSecrets() + { + var opts = new SenderOptions( + "https::addr=localhost:9000;username=alice;password=hunter2;"); + var formatted = $"{opts}"; + Assert.That(formatted, Does.Not.Contain("hunter2")); + Assert.That(formatted, Does.Not.Contain("password")); + } + + [Test] + public void Sf_DefaultsAreSane() + { + var opts = new SenderOptions("ws::addr=localhost:9000;"); + Assert.That(opts.sf_dir, Is.Null); + Assert.That(opts.sender_id, Is.EqualTo("default")); + Assert.That(opts.sf_max_bytes, Is.EqualTo(4L * 1024 * 1024)); + Assert.That(opts.sf_max_total_bytes, Is.EqualTo(128L * 1024 * 1024)); + Assert.That(opts.sf_durability, Is.EqualTo("memory")); + Assert.That(opts.sf_append_deadline_millis, Is.EqualTo(TimeSpan.FromSeconds(30))); + Assert.That(opts.reconnect_max_duration_millis, Is.EqualTo(TimeSpan.FromMinutes(5))); + Assert.That(opts.reconnect_initial_backoff_millis, Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(opts.reconnect_max_backoff_millis, Is.EqualTo(TimeSpan.FromSeconds(5))); + Assert.That(opts.initial_connect_retry, Is.False); + Assert.That(opts.close_flush_timeout_millis, Is.EqualTo(TimeSpan.FromSeconds(60))); + Assert.That(opts.drain_orphans, Is.False); + Assert.That(opts.max_background_drainers, Is.EqualTo(4)); + } + + [Test] + public void Sf_DefaultMaxTotal_GrowsWhenSfDirSet() + { + var opts = new SenderOptions("ws::addr=localhost:9000;sf_dir=/tmp/qdb;"); + Assert.That(opts.sf_max_total_bytes, Is.EqualTo(10L * 1024 * 1024 * 1024)); + } + + [Test] + public void Sf_AllKeysParse() + { + var opts = new SenderOptions( + "wss::addr=questdb.io:9000;sf_dir=/var/qdb-sf;sender_id=svc-7;" + + "sf_max_bytes=1048576;sf_max_total_bytes=10485760;sf_durability=memory;" + + "sf_append_deadline_millis=10000;reconnect_max_duration_millis=60000;" + + "reconnect_initial_backoff_millis=200;reconnect_max_backoff_millis=5000;" + + "initial_connect_retry=on;close_flush_timeout_millis=2000;" + + "drain_orphans=on;max_background_drainers=8;"); + + Assert.That(opts.sf_dir, Is.EqualTo("/var/qdb-sf")); + Assert.That(opts.sender_id, Is.EqualTo("svc-7")); + Assert.That(opts.sf_max_bytes, Is.EqualTo(1048576L)); + Assert.That(opts.sf_max_total_bytes, Is.EqualTo(10485760L)); + Assert.That(opts.sf_durability, Is.EqualTo("memory")); + Assert.That(opts.sf_append_deadline_millis, Is.EqualTo(TimeSpan.FromMilliseconds(10000))); + Assert.That(opts.reconnect_max_duration_millis, Is.EqualTo(TimeSpan.FromMilliseconds(60000))); + Assert.That(opts.reconnect_initial_backoff_millis, Is.EqualTo(TimeSpan.FromMilliseconds(200))); + Assert.That(opts.reconnect_max_backoff_millis, Is.EqualTo(TimeSpan.FromMilliseconds(5000))); + Assert.That(opts.initial_connect_retry, Is.True); + Assert.That(opts.close_flush_timeout_millis, Is.EqualTo(TimeSpan.FromMilliseconds(2000))); + Assert.That(opts.drain_orphans, Is.True); + Assert.That(opts.max_background_drainers, Is.EqualTo(8)); + } + + [Test] + public void CloseFlushTimeout_Negative_Rejected() + { + // 0 is the deliberate "fast close" opt-out (b631099); a negative is only ever a misconfig + // (e.g. a -1 "disabled" sentinel from another system) and would otherwise fold into the + // dispose-time `<= 0` guard and silently drop buffered rows in RAM mode. + var ex = Assert.Throws( + () => new SenderOptions("ws::addr=localhost:9000;close_flush_timeout_millis=-5;")); + Assert.That(ex!.Message, Does.Contain("close_flush_timeout_millis")); + + // Same gap via the programmatic TimeSpan setter. + var opts = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", + close_flush_timeout_millis = TimeSpan.FromMilliseconds(-5), + }; + Assert.That( + () => opts.EnsureValid(), + Throws.TypeOf().With.Message.Contains("close_flush_timeout_millis")); + + // 0 stays valid. + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;close_flush_timeout_millis=0;"), + Throws.Nothing); + } + + [Test] + public void ReconnectTimeouts_AboveTaskDelayCeiling_Rejected() + { + // The reconnect loop sleeps via Task.Delay, which throws once the delay exceeds ~int.MaxValue + // ms (~24.8 days). The config-string path is int-parsed so it can't express a larger value; + // the programmatic TimeSpan setters can, so EnsureValid must reject them rather than let the + // engine fault mid-outage with a raw ArgumentOutOfRangeException. + var overCeiling = TimeSpan.FromMilliseconds((double)int.MaxValue + 1); + + var dur = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", reconnect_max_duration_millis = overCeiling, + }; + Assert.That(() => dur.EnsureValid(), + Throws.TypeOf().With.Message.Contains("reconnect_max_duration_millis")); + + var init = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", reconnect_initial_backoff_millis = overCeiling, + }; + Assert.That(() => init.EnsureValid(), + Throws.TypeOf().With.Message.Contains("reconnect_initial_backoff_millis")); + + var back = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", reconnect_max_backoff_millis = overCeiling, + }; + Assert.That(() => back.EnsureValid(), + Throws.TypeOf().With.Message.Contains("reconnect_max_backoff_millis")); + + // Exactly at the ceiling stays valid. + var atCeiling = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", + reconnect_max_duration_millis = TimeSpan.FromMilliseconds(int.MaxValue), + }; + Assert.That(() => atCeiling.EnsureValid(), Throws.Nothing); + + // The config-string path can't even express an over-ceiling value (int-parsed). + Assert.That( + () => new SenderOptions("ws::addr=h:9000;reconnect_max_backoff_millis=2147483648;"), + Throws.TypeOf().With.Message.Contains("reconnect_max_backoff_millis")); + } + + [Test] + public void Sf_DurabilityNonMemory_Throws() + { + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;sf_dir=/tmp;sf_durability=disk;"), + Throws.TypeOf().With.Message.Contains("sf_durability")); + } + + [Test] + public void Sf_KeysOnHttpScheme_Throws() + { + Assert.That( + () => new SenderOptions("http::addr=localhost:9000;sf_dir=/tmp;"), + Throws.TypeOf().With.Message.Contains("ws::")); + } + + [Test] + public void NonSfWsKeys_OnHttpScheme_RejectedIndividually() + { + var keys = new[] + { + "request_durable_ack=on", + }; + foreach (var kv in keys) + { + Assert.That( + () => new SenderOptions($"http::addr=localhost:9000;{kv};"), + Throws.TypeOf(), + $"key `{kv.Split('=')[0]}` must be rejected on http scheme"); + } + } + + [Test] + public void TokenXY_SilentlyAccepted_ForCrossClientInterop() + { + Assert.DoesNotThrow(() => new SenderOptions( + "tcp::addr=localhost:9009")); + } + + [Test] + public void AutoFlushOff_ZerosAllTriggers() + { + var opts = new SenderOptions("http::addr=localhost:9000;auto_flush=off;"); + Assert.That(opts.auto_flush_rows, Is.EqualTo(-1)); + Assert.That(opts.auto_flush_bytes, Is.EqualTo(-1)); + Assert.That(opts.auto_flush_interval, Is.EqualTo(TimeSpan.FromMilliseconds(-1))); + } + + [Test] + public void AutoFlushRowsZero_Rejected() + { + Assert.Throws(() => + new SenderOptions("http::addr=localhost:9000;auto_flush_rows=0;")); + } + + [Test] + public void AutoFlushIntervalZero_Rejected() + { + Assert.Throws(() => + new SenderOptions("http::addr=localhost:9000;auto_flush_interval=0;")); + } + + [Test] + public void AutoFlushBytesZero_Accepted() + { + var opts = new SenderOptions("http::addr=localhost:9000;auto_flush_bytes=0;"); + Assert.That(opts.auto_flush_bytes, Is.EqualTo(-1)); + } + + [Test] + public void Ws_DefaultPort_NotProvided() + { + var opts = new SenderOptions("ws::addr=localhost;"); + Assert.That(opts.Port, Is.EqualTo(9000)); + } + + [Test] + public void Wss_DefaultPort_NotProvided() + { + var opts = new SenderOptions("wss::addr=localhost;"); + Assert.That(opts.Port, Is.EqualTo(9000)); + } + + [Test] + public void UserPassAliases_AcceptedAsUsernamePassword() + { + var opts = new SenderOptions("http::addr=localhost:9000;user=alice;pass=secret;"); + Assert.That(opts.username, Is.EqualTo("alice")); + Assert.That(opts.password, Is.EqualTo("secret")); + } + + [Test] + public void UsernameWinsOverUserAlias() + { + var opts = new SenderOptions("http::addr=localhost:9000;username=primary;user=alias;password=p;"); + Assert.That(opts.username, Is.EqualTo("primary")); + } + + [Test] + public void SenderId_PathTraversal_Rejected() + { + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;sf_dir=/tmp/qdb;sender_id=../etc;"), + Throws.TypeOf().With.Message.Contains("sender_id")); + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;sf_dir=/tmp/qdb;sender_id=a/b;"), + Throws.TypeOf().With.Message.Contains("sender_id")); + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;sf_dir=/tmp/qdb;sender_id=/abs;"), + Throws.TypeOf().With.Message.Contains("sender_id")); + } + + [Test] + public void SenderId_NormalSegment_Accepted() + { + Assert.DoesNotThrow( + () => new SenderOptions("ws::addr=localhost:9000;sf_dir=/tmp/qdb;sender_id=service-7;")); + } + + [Test] + public void Ws_Defaults() + { + var opts = new SenderOptions("ws::addr=localhost:9000;"); + Assert.That(opts.auto_flush_rows, Is.EqualTo(1000)); + Assert.That(opts.auto_flush_bytes, Is.EqualTo(8 * 1024 * 1024)); + Assert.That(opts.auto_flush_interval, Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(opts.Port, Is.EqualTo(9000)); + Assert.That(opts.request_durable_ack, Is.False); + } + + [Test] + public void UnknownKey_IsRejected() + { + var ex = Assert.Throws( + () => new SenderOptions("ws::addr=localhost:9000;some_unrecognised_key=42;")); + Assert.That(ex!.Message, Does.Contain("some_unrecognised_key")); + } + + [TestCase("ws", "max_datagram_size=2048")] + [TestCase("ws", "multicast_ttl=4")] + [TestCase("wss", "max_datagram_size=2048")] + [TestCase("wss", "multicast_ttl=4")] + public void UdpOnlyKey_RejectedOnWebSocket(string scheme, string keyValue) + { + // max_datagram_size / multicast_ttl are UDP-only ingress knobs with no transport in this + // client. They are absent from the QWP connect-string vocabulary, so the WebSocket ingress + // parser rejects them as unknown keys. + var key = keyValue.Substring(0, keyValue.IndexOf('=')); + var ex = Assert.Throws( + () => new SenderOptions($"{scheme}::addr=localhost:9000;{keyValue};")); + Assert.That(ex!.Message, Does.Contain(key)); + } + + [Test] + public void TokenXY_AcceptedOnIlpRejectedOnWs() + { + // token_x / token_y are ECDSA P-256 key components for legacy ILP/TCP auth. They are not + // part of the QWP connect-string vocabulary, so the WebSocket ingress path rejects them + // (QWP auth is Basic / Bearer); http/tcp still accept them for cross-client interop. + Assert.That( + () => new SenderOptions("tcp::addr=localhost:9009;token_x=abc;token_y=def;"), + Throws.Nothing); + var ex = Assert.Throws( + () => new SenderOptions("ws::addr=localhost:9000;token_x=abc;token_y=def;")); + Assert.That(ex!.Message, Does.Contain("not supported for QWP/WebSocket transport")); + } + + [Test] + public void Zone_IsAcceptedOnAllSchemesForCrossClientInterop() + { + Assert.That(() => new SenderOptions("ws::addr=localhost:9000;zone=eu-west-1a;"), Throws.Nothing); + Assert.That(() => new SenderOptions("http::addr=localhost:9000;zone=eu-west-1a;"), Throws.Nothing); + Assert.That(() => new SenderOptions("tcp::addr=localhost:9009;zone=eu-west-1a;"), Throws.Nothing); + } + + [Test] + public void Target_IsSilentlyAcceptedOnWebSocketButRejectedOnIngress() + { + // `target` is an egress-only key: accepted-and-ignored on ws/wss, rejected on http/tcp + // like every other egress key (mirrors EgressKeys_RejectedOnNonWebSocketScheme). + Assert.That(() => new SenderOptions("ws::addr=localhost:9000;target=replica;"), Throws.Nothing); + Assert.That(() => new SenderOptions("http::addr=localhost:9000;target=primary;"), + Throws.TypeOf().With.Message.Contains("target")); + Assert.That(() => new SenderOptions("tcp::addr=localhost:9009;target=any;"), + Throws.TypeOf().With.Message.Contains("target")); + } + + [Test] + public void InitialCredit_IsSilentlyAcceptedOnWebSocketButRejectedOnIngress() + { + Assert.That(() => new SenderOptions("ws::addr=localhost:9000;initial_credit=1024;"), Throws.Nothing); + Assert.That(() => new SenderOptions("http::addr=localhost:9000;initial_credit=1024;"), + Throws.TypeOf().With.Message.Contains("initial_credit")); + Assert.That(() => new SenderOptions("tcp::addr=localhost:9009;initial_credit=1024;"), + Throws.TypeOf().With.Message.Contains("initial_credit")); + } + + [Test] + public void Transaction_IsWebSocketOnly_RejectedOnIngress() + { + // `transaction` is WebSocket-only, matching the Java client which throws + // "transaction is only supported for WebSocket transport" on http/tcp. It was previously + // silently accepted-and-ignored on every scheme. + Assert.That(() => new SenderOptions("http::addr=localhost:9000;transaction=on;"), + Throws.TypeOf().With.Message.Contains("transaction")); + Assert.That(() => new SenderOptions("tcp::addr=localhost:9009;transaction=on;"), + Throws.TypeOf().With.Message.Contains("transaction")); + Assert.That(() => new SenderOptions("http::addr=localhost:9000;transaction=off;"), + Throws.TypeOf().With.Message.Contains("transaction")); + } + + [Test] + public void Transaction_OnWebSocket_ParsesOnOff() + { + // WS transactional mode (defer-commit). on/off map to the `transaction` bool; an invalid + // value is rejected. + Assert.That(new SenderOptions("ws::addr=localhost:9000;transaction=on;").transaction, Is.True); + Assert.That(new SenderOptions("wss::addr=localhost:9000;transaction=on;").transaction, Is.True); + Assert.That(new SenderOptions("ws::addr=localhost:9000;transaction=off;").transaction, Is.False); + Assert.That(new SenderOptions("ws::addr=localhost:9000;").transaction, Is.False); + + Assert.That(() => new SenderOptions("ws::addr=localhost:9000;transaction=maybe;"), + Throws.TypeOf().With.Message.Contains("transaction")); + } + + [Test] + public void Transaction_ProgrammaticOnNonWebSocket_Rejected() + { + var opts = new SenderOptions("http::addr=localhost:9000;") { transaction = true }; + Assert.That(() => opts.EnsureValid(), + Throws.TypeOf().With.Message.Contains("transaction")); + } + + [Test] + public void Transaction_ToString_RoundTrips() + { + var opts = new SenderOptions("ws::addr=localhost:9000;transaction=on;"); + var round = new SenderOptions(opts.ToString()); + Assert.That(round.transaction, Is.True); + } + + [Test] + public void EgressKeys_SilentlyAcceptedOnWebSocket() + { + Assert.That(() => new SenderOptions( + "ws::addr=localhost:9000;compression=zstd;compression_level=6;failover=on;" + + "failover_max_attempts=4;max_batch_rows=5000;client_id=app-1;buffer_pool_size=8;" + + "path=/read/v1;"), Throws.Nothing); + } + + [Test] + public void EgressKeys_RejectedOnNonWebSocketScheme() + { + var ex = Assert.Throws( + () => new SenderOptions("http::addr=localhost:9000;compression=zstd;")); + Assert.That(ex!.Message, Does.Contain("compression")); + } + + [Test] + public void Ws_AuthTimeoutMs_Parses() + { + var withMs = new SenderOptions("ws::addr=localhost:9000;auth_timeout_ms=2500;"); + Assert.That(withMs.auth_timeout, Is.EqualTo(TimeSpan.FromMilliseconds(2500))); + } + + [Test] + public void Ws_LegacyAuthTimeout_Rejected() + { + // The QWP connect-string vocabulary uses auth_timeout_ms; the bare legacy ILP name + // auth_timeout is rejected on the WebSocket path. It stays valid on http/tcp. + var ex = Assert.Throws( + () => new SenderOptions("ws::addr=localhost:9000;auth_timeout=2500;")); + Assert.That(ex!.Message, Does.Contain("not supported for QWP/WebSocket transport")); + + var http = new SenderOptions("http::addr=localhost:9000;auth_timeout=2500;"); + Assert.That(http.auth_timeout, Is.EqualTo(TimeSpan.FromMilliseconds(2500))); + } + + [Test] + public void Ws_ToString_EmitsAuthTimeoutMs() + { + var opts = new SenderOptions("ws::addr=localhost:9000;auth_timeout_ms=4000;"); + var s = opts.ToString(); + Assert.That(s, Does.Contain("auth_timeout_ms=4000")); + Assert.That(s, Does.Not.Contain("auth_timeout=4000")); + } + + [Test] + public void Http_ToString_KeepsAuthTimeoutLegacyName() + { + var opts = new SenderOptions("http::addr=localhost:9000;auth_timeout=4000;"); + Assert.That(opts.ToString(), Does.Contain("auth_timeout=4000")); + } + + [TestCase("off", InitialConnectMode.off)] + [TestCase("false", InitialConnectMode.off)] + [TestCase("OFF", InitialConnectMode.off)] + [TestCase("on", InitialConnectMode.on)] + [TestCase("true", InitialConnectMode.on)] + [TestCase("sync", InitialConnectMode.on)] + [TestCase("SYNC", InitialConnectMode.on)] + [TestCase("async", InitialConnectMode.async)] + [TestCase("ASYNC", InitialConnectMode.async)] + public void InitialConnectRetry_AcceptsAllAliases(string raw, InitialConnectMode expected) + { + var opts = new SenderOptions($"ws::addr=localhost:9000;sf_dir=/tmp/qdb;initial_connect_retry={raw};"); + Assert.That(opts.initial_connect_mode, Is.EqualTo(expected)); + } + + [Test] + public void InitialConnectRetry_OmittedDefaultsToOff() + { + var opts = new SenderOptions("ws::addr=localhost:9000;"); + Assert.That(opts.initial_connect_mode, Is.EqualTo(InitialConnectMode.off)); + Assert.That(opts.initial_connect_retry, Is.False); + } + + [Test] + public void InitialConnectRetry_BoolGetterMapsModes() + { + var off = new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/qdb;initial_connect_retry=off;"); + Assert.That(off.initial_connect_retry, Is.False); + + var on = new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/qdb;initial_connect_retry=on;"); + Assert.That(on.initial_connect_retry, Is.True); + + var async = new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/qdb;initial_connect_retry=async;"); + Assert.That(async.initial_connect_retry, Is.True); + Assert.That(async.initial_connect_mode, Is.EqualTo(InitialConnectMode.async)); + } + + [Test] + public void InitialConnectRetry_InvalidValue_Rejected() + { + Assert.That( + () => new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/qdb;initial_connect_retry=eventually;"), + Throws.TypeOf()); + } + + [TestCase("on")] + [TestCase("async")] + public void InitialConnectRetry_WithoutSfDir_AcceptedAfterCursorEngineUnification(string mode) + { + var opts = new SenderOptions($"ws::addr=h:9000;initial_connect_retry={mode};"); + Assert.That(opts.initial_connect_retry, Is.True); + } + + [TestCase("reconnect_max_duration_millis=60000")] + [TestCase("reconnect_initial_backoff_millis=200")] + [TestCase("reconnect_max_backoff_millis=5000")] + public void InitialConnectRetry_PromotedToOn_WhenReconnectKeyTuned(string reconnectKey) + { + var opts = new SenderOptions($"ws::addr=h:9000;{reconnectKey};"); + Assert.That(opts.initial_connect_mode, Is.EqualTo(InitialConnectMode.on)); + } + + [Test] + public void InitialConnectRetry_ExplicitOff_SurvivesReconnectKeyTuning() + { + var opts = new SenderOptions( + "ws::addr=h:9000;reconnect_max_duration_millis=60000;initial_connect_retry=off;"); + Assert.That(opts.initial_connect_mode, Is.EqualTo(InitialConnectMode.off)); + } + + [Test] + public void InitialConnectRetry_PromotedToOn_WhenReconnectKeyTunedProgrammatically() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000" }; + opts.reconnect_max_backoff_millis = TimeSpan.FromSeconds(10); + opts.EnsureValid(); + Assert.That(opts.initial_connect_mode, Is.EqualTo(InitialConnectMode.on)); + } + + [Test] + public void ErrorHandler_WithoutSfDir_AcceptedAfterCursorEngineUnification() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000" }; + opts.error_handler = _ => { }; + Assert.DoesNotThrow(() => opts.EnsureValid()); + Assert.That(opts.error_handler, Is.Not.Null); + } + + [Test] + public void ErrorHandler_WithSfDir_PassesValidation() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000", sf_dir = "/tmp/qdb" }; + opts.error_handler = _ => { }; + Assert.DoesNotThrow(() => opts.EnsureValid()); + Assert.That(opts.error_handler, Is.Not.Null); + } + + [Test] + public void ErrorHandler_OnHttpScheme_Rejected() + { + var opts = new SenderOptions { protocol = ProtocolType.http, addr = "h:9000" }; + opts.error_handler = _ => { }; + Assert.That( + () => opts.EnsureValid(), + Throws.TypeOf().With.Message.Contains("ws")); + } + + [Test] + public void ErrorPolicyResolver_WithoutSfDir_AcceptedAfterCursorEngineUnification() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000" }; + opts.error_policy_resolver = _ => SenderErrorPolicy.Halt; + Assert.DoesNotThrow(() => opts.EnsureValid()); + } + + [Test] + public void ErrorPolicyResolver_WithSfDir_PassesValidation() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000", sf_dir = "/tmp/qdb" }; + opts.error_policy_resolver = _ => SenderErrorPolicy.Halt; + Assert.DoesNotThrow(() => opts.EnsureValid()); + } + + [Test] + public void ErrorInboxCapacity_WithoutSfDir_AcceptedAfterCursorEngineUnification() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000", error_inbox_capacity = 16 }; + Assert.DoesNotThrow(() => opts.EnsureValid()); + } + + [Test] + public void ErrorInboxCapacity_BelowMinimum_Rejected() + { + var opts = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", sf_dir = "/tmp/qdb", error_inbox_capacity = 15, + }; + Assert.That( + () => opts.EnsureValid(), + Throws.TypeOf().With.Message.Contains(">= 16")); + } + + [Test] + public void ErrorInboxCapacity_DefaultIs256() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000", sf_dir = "/tmp/qdb" }; + Assert.That(opts.error_inbox_capacity, Is.EqualTo(256)); + Assert.DoesNotThrow(() => opts.EnsureValid()); + } + + [TestCase("halt", SenderErrorPolicy.Halt)] + [TestCase("HALT", SenderErrorPolicy.Halt)] + [TestCase("drop", SenderErrorPolicy.DropAndContinue)] + [TestCase("DROP", SenderErrorPolicy.DropAndContinue)] + [TestCase("drop_and_continue", SenderErrorPolicy.DropAndContinue)] + public void OnServerError_AcceptsHaltAndDropAliases(string raw, SenderErrorPolicy expected) + { + var opts = new SenderOptions( + $"ws::addr=h:9000;sf_dir=/tmp/qdb;on_server_error={raw};"); + Assert.That(opts.on_server_error, Is.EqualTo(expected)); + } + + [Test] + public void OnServerError_InvalidValue_Rejected() + { + Assert.That( + () => new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/qdb;on_server_error=maybe;"), + Throws.TypeOf().With.Message.Contains("on_server_error")); + } + + [Test] + public void OnServerError_WithoutSfDir_AcceptedAfterCursorEngineUnification() + { + var opts = new SenderOptions("ws::addr=h:9000;on_server_error=halt;"); + Assert.That(opts.on_server_error, Is.EqualTo(SenderErrorPolicy.Halt)); + } + + [Test] + public void OnServerError_OnHttpScheme_Rejected() + { + Assert.That( + () => new SenderOptions("http::addr=h:9000;on_server_error=halt;"), + Throws.TypeOf()); + } + + [Test] + public void OnPerCategoryError_Parses() + { + var opts = new SenderOptions( + "ws::addr=h:9000;sf_dir=/tmp/qdb;" + + "on_schema_mismatch_error=halt;on_parse_error=drop;" + + "on_internal_error=drop_and_continue;on_security_error=halt;on_write_error=halt;"); + Assert.That(opts.on_schema_mismatch_error, Is.EqualTo(SenderErrorPolicy.Halt)); + Assert.That(opts.on_parse_error, Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(opts.on_internal_error, Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(opts.on_security_error, Is.EqualTo(SenderErrorPolicy.Halt)); + Assert.That(opts.on_write_error, Is.EqualTo(SenderErrorPolicy.Halt)); + } + + [Test] + public void EffectiveResolver_Null_WhenNothingSet() + { + var opts = new SenderOptions { protocol = ProtocolType.ws, addr = "h:9000", sf_dir = "/tmp/qdb" }; + opts.EnsureValid(); + Assert.That(opts.BuildEffectivePolicyResolver(), Is.Null); + } + + [Test] + public void EffectiveResolver_PerCategoryWinsOverGlobal() + { + var opts = new SenderOptions( + "ws::addr=h:9000;sf_dir=/tmp/qdb;" + + "on_server_error=halt;on_schema_mismatch_error=drop;"); + var resolver = opts.BuildEffectivePolicyResolver(); + Assert.That(resolver, Is.Not.Null); + Assert.That(resolver!(SenderErrorCategory.SchemaMismatch), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(resolver(SenderErrorCategory.ParseError), Is.EqualTo(SenderErrorPolicy.Halt)); + Assert.That(resolver(SenderErrorCategory.InternalError), Is.EqualTo(SenderErrorPolicy.Halt)); + Assert.That(resolver(SenderErrorCategory.WriteError), Is.EqualTo(SenderErrorPolicy.Halt)); + } + + [Test] + public void EffectiveResolver_GlobalAppliesToOverridableOnly() + { + var opts = new SenderOptions( + "ws::addr=h:9000;sf_dir=/tmp/qdb;on_server_error=drop;"); + var resolver = opts.BuildEffectivePolicyResolver(); + Assert.That(resolver, Is.Not.Null); + Assert.That(resolver!(SenderErrorCategory.SchemaMismatch), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(resolver(SenderErrorCategory.ParseError), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(resolver(SenderErrorCategory.InternalError), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(resolver(SenderErrorCategory.SecurityError), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(resolver(SenderErrorCategory.WriteError), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + } + + [Test] + public void EffectiveResolver_ProgrammaticBeatsConnectString() + { + var opts = new SenderOptions( + "ws::addr=h:9000;sf_dir=/tmp/qdb;on_server_error=halt;on_schema_mismatch_error=halt;"); + opts.error_policy_resolver = _ => SenderErrorPolicy.DropAndContinue; + var resolver = opts.BuildEffectivePolicyResolver(); + Assert.That(resolver!(SenderErrorCategory.SchemaMismatch), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(resolver(SenderErrorCategory.ParseError), Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + } + + [Test] + public void OnServerError_InWebSocketOnlyKeysList() + { + Assert.That( + () => new SenderOptions("http::addr=h:9000;on_schema_mismatch_error=halt;"), + Throws.TypeOf()); + } + + [Test] + public void OnServerError_RoundTripsViaToString() + { + var original = new SenderOptions( + "ws::addr=h:9000;sf_dir=/tmp/qdb;" + + "on_server_error=halt;on_schema_mismatch_error=drop;on_write_error=drop_and_continue;"); + var roundTripped = new SenderOptions(original.ToString()); + Assert.That(roundTripped.on_server_error, Is.EqualTo(SenderErrorPolicy.Halt)); + Assert.That(roundTripped.on_schema_mismatch_error, Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + Assert.That(roundTripped.on_write_error, Is.EqualTo(SenderErrorPolicy.DropAndContinue)); + } + + [Test] + public void AuthPrecedence_UsernameAndToken_BothPresent_Rejected() + { + Assert.That( + () => new SenderOptions("http::addr=localhost:9000;username=alice;token=t123;"), + Throws.TypeOf()); + } + + [Test] + public void AuthPrecedence_UsernameWithoutPassword_Rejected() + { + Assert.That( + () => new SenderOptions("http::addr=localhost:9000;username=alice;"), + Throws.TypeOf()); + } + + [Test] + public void AuthPrecedence_PasswordWithoutUsername_Rejected() + { + Assert.That( + () => new SenderOptions("http::addr=localhost:9000;password=secret;"), + Throws.TypeOf()); + } + + [Test] + public void Gzip_OnWithWebSocketScheme_Rejected() + { + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;gzip=true;"), + Throws.TypeOf()); + Assert.That( + () => new SenderOptions("wss::addr=localhost:9000;gzip=true;"), + Throws.TypeOf()); + } + + [Test] + public void Gzip_OffWithWebSocketScheme_Rejected() + { + // gzip is an ILP-only key absent from the QWP vocabulary, so the WebSocket path rejects + // it by name regardless of value -- gzip=false is rejected just like gzip=true. + var ex = Assert.Throws( + () => new SenderOptions("ws::addr=localhost:9000;gzip=false;")); + Assert.That(ex!.Message, Does.Contain("not supported for QWP/WebSocket transport")); + } + + [Test] + public void Tls_VerifyKeysAccepted() + { + Assert.DoesNotThrow(() => new SenderOptions("https::addr=localhost:9000;tls_verify=on;")); + Assert.DoesNotThrow(() => new SenderOptions("https::addr=localhost:9000;tls_verify=unsafe_off;")); + } + + [Test] + public void Tls_RootsAndPasswordAccepted() + { + Assert.DoesNotThrow(() => new SenderOptions( + "https::addr=localhost:9000;tls_roots=/tmp/ca.pem;tls_roots_password=secret;")); + } + + [Test] + public void Tls_RootsPasswordWithoutRoots_Rejected() + { + Assert.That( + () => new SenderOptions("https::addr=localhost:9000;tls_roots_password=secret;"), + Throws.TypeOf()); + } + + [Test] + public void MultiAddress_AcceptedForWebSocket() + { + var ws = new SenderOptions("ws::addr=h1:9000;addr=h2:9000;"); + Assert.That(ws.addresses, Is.EqualTo(new[] { "h1:9000", "h2:9000" })); + var wss = new SenderOptions("wss::addr=h1:9000;addr=h2:9000;"); + Assert.That(wss.addresses, Is.EqualTo(new[] { "h1:9000", "h2:9000" })); + } + + [Test] + public void Sf_AllKeysOnHttpScheme_RejectedIndividually() + { + var keys = new[] + { + "sender_id=foo", "sf_max_bytes=1024", "sf_max_total_bytes=1024", "sf_durability=memory", + "sf_append_deadline_millis=1000", "reconnect_max_duration_millis=1000", + "reconnect_initial_backoff_millis=1", "reconnect_max_backoff_millis=1", + "initial_connect_retry=on", "close_flush_timeout_millis=100", + "drain_orphans=on", "max_background_drainers=2", + }; + foreach (var kv in keys) + { + Assert.That( + () => new SenderOptions($"http::addr=localhost:9000;{kv};"), + Throws.TypeOf(), + $"key `{kv.Split('=')[0]}` must be rejected on http scheme"); + } + } + + [Test] + public void RecordWith_FlippingWsToHttp_StillRejectsWsOnlyKeys() + { + var ws = new SenderOptions("ws::addr=localhost:9000;request_durable_ack=on;"); + var flipped = ws with { protocol = QuestDB.Enums.ProtocolType.http }; + + Assert.That( + () => QuestDB.Sender.New(flipped), + Throws.TypeOf().With.Message.Contains("request_durable_ack")); + } + + [Test] + public void Programmatic_HttpSenderWithWsOnlyKey_Rejected() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.http, + addr = "localhost:9000", + request_durable_ack = true, + }; + + Assert.That( + () => QuestDB.Sender.New(opts), + Throws.TypeOf().With.Message.Contains("request_durable_ack")); + } + + [TestCase("on", true)] + [TestCase("ON", true)] + [TestCase("On", true)] + [TestCase("off", false)] + [TestCase("OFF", false)] + [TestCase("Off", false)] + [TestCase("true", true)] + [TestCase("TRUE", true)] + [TestCase("True", true)] + [TestCase("false", false)] + [TestCase("FALSE", false)] + public void Gzip_AcceptsBothBooleanForms(string raw, bool expected) + { + var opts = new SenderOptions($"http::addr=localhost:9000;gzip={raw};"); + Assert.That(opts.gzip, Is.EqualTo(expected)); + } + + [TestCase("on", true)] + [TestCase("OFF", false)] + [TestCase("True", true)] + public void RequestDurableAck_AcceptsBothBooleanForms(string raw, bool expected) + { + var opts = new SenderOptions($"ws::addr=localhost:9000;request_durable_ack={raw};"); + Assert.That(opts.request_durable_ack, Is.EqualTo(expected)); + } + + [TestCase("yes")] + [TestCase("1")] + [TestCase("0")] + [TestCase("")] + [TestCase("nope")] + public void BoolKey_RejectsNonBooleanLiterals(string raw) + { + Assert.That( + () => new SenderOptions($"http::addr=localhost:9000;gzip={raw};"), + Throws.TypeOf()); + } + + [Test] + public void GzipOn_ViaWebSocketScheme_GivesGzipRejectionNotParseError() + { + // gzip is a legacy ILP HTTP key (binary QWP has no gzip), so the WebSocket path rejects + // it as a non-QWP key rather than letting it through to a later parse/validate error. + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;gzip=on;"), + Throws.TypeOf().With.Message.Contains("not supported for QWP/WebSocket transport")); + } + + [Test] + public void RecordWith_MutatingWsKeyAfterFlip_StillRejected() + { + var ws = new SenderOptions("ws::addr=localhost:9000;"); + var flipped = ws with { protocol = QuestDB.Enums.ProtocolType.http, request_durable_ack = true }; + + Assert.That( + () => QuestDB.Sender.New(flipped), + Throws.TypeOf().With.Message.Contains("request_durable_ack")); + } + + [Test] + public void TcpUsernameAndToken_ParseTogether() + { + var opts = new SenderOptions("tcp::addr=localhost:9009;username=admin;token=secret;"); + Assert.That(opts.username, Is.EqualTo("admin")); + Assert.That(opts.token, Is.EqualTo("secret")); + } + + [Test] + public void Programmatic_HttpUsernameWithoutPassword_RejectedByEnsureValid() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.http, + addr = "localhost:9000", + username = "alice", + }; + + Assert.That( + () => QuestDB.Sender.New(opts), + Throws.TypeOf()); + } + + [Test] + public void Programmatic_TcpUsernameAndTokenAccepted() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.tcp, + addr = "localhost:9009", + username = "admin", + token = "secret", + }; + Assert.DoesNotThrow(() => opts.EnsureValid()); + } + + [Test] + public void Programmatic_WsKeySetToDefaultValue_OnHttp_StillRejected() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.http, + addr = "localhost:9000", + request_durable_ack = false, // default value, still rejected on non-WS scheme + }; + + Assert.That( + () => QuestDB.Sender.New(opts), + Throws.TypeOf().With.Message.Contains("request_durable_ack")); + } + + [Test] + public void Programmatic_NoWsKeysTouched_OnHttp_Allowed() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.http, + addr = "localhost:9000", + }; + + Assert.DoesNotThrow(() => opts.EnsureValid()); + } + + [Test] + public void AutoFlushOff_OnWebSocketScheme_AlsoZerosTriggers() + { + var opts = new SenderOptions("ws::addr=localhost:9000;auto_flush=off;"); + Assert.That(opts.auto_flush_rows, Is.EqualTo(-1)); + Assert.That(opts.auto_flush_bytes, Is.EqualTo(-1)); + Assert.That(opts.auto_flush_interval, Is.EqualTo(TimeSpan.FromMilliseconds(-1))); + } + + [Test] + public void Ws_ToString_RoundTripsWithWsOnlyKeys() + { + var opts = new SenderOptions( + "ws::addr=h:9000;request_durable_ack=on;ping_timeout=2500;"); + var rt = new SenderOptions(opts.ToString()); + Assert.That(rt.request_durable_ack, Is.True); + Assert.That(rt.ping_timeout, Is.EqualTo(TimeSpan.FromMilliseconds(2500))); + } + + [Test] + public void PingTimeout_OnHttpScheme_Rejected() + { + Assert.That( + () => new SenderOptions("http::addr=localhost:9000;ping_timeout=1000;"), + Throws.TypeOf().With.Message.Contains("ping_timeout")); + } + + [Test] + public void AddrSetter_RefreshesAddresses() + { + var opts = new SenderOptions { protocol = QuestDB.Enums.ProtocolType.ws, addr = "h1:9000,h2:9000,h3:9000" }; + Assert.That(opts.AddressCount, Is.EqualTo(3)); + Assert.That(opts.addresses[0], Is.EqualTo("h1:9000")); + Assert.That(opts.addresses[2], Is.EqualTo("h3:9000")); + } + + [Test] + public void AddrSetter_OverwritesPreviousList() + { + var opts = new SenderOptions { protocol = QuestDB.Enums.ProtocolType.ws, addr = "h1:9000,h2:9000" }; + opts.addr = "single:9000"; + Assert.That(opts.AddressCount, Is.EqualTo(1)); + Assert.That(opts.addresses[0], Is.EqualTo("single:9000")); + } + + [Test] + public void Proxy_OnHttpScheme_Programmatic_Rejected() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.http, + addr = "localhost:9000", + proxy = "http://p:8080", + }; + Assert.That(() => QuestDB.Sender.New(opts), Throws.TypeOf().With.Message.Contains("proxy")); + } + + [Test] + public void Proxy_OnHttpScheme_String_Rejected() + { + Assert.That( + () => new SenderOptions("http::addr=localhost:9000;proxy=http://p:8080;"), + Throws.TypeOf().With.Message.Contains("proxy")); + } + + [Test] + public void SfMaxTotalBytes_LessThanTwiceSfMaxBytes_Rejected() + { + Assert.That( + () => new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/test;sf_max_bytes=8000000;sf_max_total_bytes=10000000;"), + Throws.TypeOf().With.Message.Contains("sf_max_total_bytes")); + } + + [Test] + public void SfMaxBytes_NonPositive_Rejected() + { + Assert.That( + () => new SenderOptions("ws::addr=h:9000;sf_dir=/tmp/test;sf_max_bytes=0;"), + Throws.TypeOf().With.Message.Contains("sf_max_bytes")); + } + + [Test] + public void Tcp_MultiAddr_Rejected() + { + Assert.That( + () => new SenderOptions("tcp::addr=h1:9009,h2:9009;"), + Throws.TypeOf().With.Message.Contains("tcp")); + } + + [TestCase("http", "request_durable_ack=on")] + [TestCase("http", "sf_dir=/tmp/x")] + [TestCase("http", "sender_id=foo")] + [TestCase("http", "ping_timeout=1000")] + [TestCase("http", "proxy=http://p:8080")] + [TestCase("http", "target=primary")] + [TestCase("http", "initial_credit=5")] + [TestCase("https", "ping_timeout=1000")] + [TestCase("https", "proxy=http://p:8080")] + [TestCase("https", "target=replica")] + [TestCase("https", "initial_credit=5")] + [TestCase("tcp", "ping_timeout=1000")] + [TestCase("tcp", "proxy=http://p:8080")] + [TestCase("tcp", "target=primary")] + [TestCase("tcp", "initial_credit=5")] + [TestCase("tcps", "ping_timeout=1000")] + [TestCase("tcps", "proxy=http://p:8080")] + public void WsOnlyKey_OnNonWsScheme_Rejected(string scheme, string kv) + { + var addr = scheme.StartsWith("tcp") ? "addr=localhost:9009" : "addr=localhost:9000"; + Assert.That( + () => new SenderOptions($"{scheme}::{addr};{kv};"), + Throws.TypeOf(), + $"key `{kv.Split('=')[0]}` must be rejected on {scheme} scheme"); + } + + [TestCase("ws", "protocol_version=2")] + [TestCase("ws", "protocol_version=auto")] + [TestCase("ws", "request_timeout=5000")] + [TestCase("ws", "retry_timeout=10000")] + [TestCase("ws", "request_min_throughput=1024")] + [TestCase("ws", "gzip=on")] + [TestCase("ws", "pool_timeout=30000")] + [TestCase("ws", "own_socket=on")] + [TestCase("ws", "token_x=abc")] + [TestCase("ws", "token_y=def")] + [TestCase("ws", "auth_timeout=15000")] + [TestCase("wss", "protocol_version=2")] + [TestCase("wss", "protocol_version=auto")] + [TestCase("wss", "request_timeout=5000")] + [TestCase("wss", "retry_timeout=10000")] + [TestCase("wss", "request_min_throughput=1024")] + [TestCase("wss", "gzip=on")] + [TestCase("wss", "token_x=abc")] + [TestCase("wss", "auth_timeout=15000")] + public void IlpHttpOnlyKey_OnWsScheme_Rejected(string scheme, string kv) + { + var ex = Assert.Throws( + () => new SenderOptions($"{scheme}::addr=localhost:9000;{kv};")); + Assert.That(ex!.Message, Does.Contain("not supported for QWP/WebSocket transport"), + $"key `{kv.Split('=')[0]}` must be rejected on {scheme} scheme"); + } + + [TestCase("http", "protocol_version=2")] + [TestCase("http", "protocol_version=auto")] + [TestCase("http", "request_timeout=5000")] + [TestCase("http", "retry_timeout=10000")] + [TestCase("http", "request_min_throughput=1024")] + [TestCase("http", "gzip=on")] + [TestCase("http", "pool_timeout=30000")] + [TestCase("http", "own_socket=on")] + [TestCase("http", "token_x=abc")] + [TestCase("http", "token_y=def")] + [TestCase("http", "auth_timeout=15000")] + [TestCase("https", "protocol_version=2")] + [TestCase("https", "request_timeout=5000")] + [TestCase("https", "retry_timeout=10000")] + [TestCase("https", "request_min_throughput=1024")] + [TestCase("https", "gzip=on")] + [TestCase("tcp", "protocol_version=2")] + [TestCase("tcp", "request_timeout=5000")] + [TestCase("tcp", "retry_timeout=10000")] + [TestCase("tcp", "request_min_throughput=1024")] + [TestCase("tcp", "auth_timeout=15000")] + public void IlpHttpOnlyKey_OnIlpScheme_Accepted(string scheme, string kv) + { + var addr = scheme.StartsWith("tcp") ? "addr=localhost:9009" : "addr=localhost:9000"; + Assert.DoesNotThrow( + () => new SenderOptions($"{scheme}::{addr};{kv};"), + $"key `{kv.Split('=')[0]}` must be accepted on {scheme} scheme"); + } } \ No newline at end of file diff --git a/src/net-questdb-client-tests/TcpProxy.cs b/src/net-questdb-client-tests/TcpProxy.cs new file mode 100644 index 00000000..fc0502bd --- /dev/null +++ b/src/net-questdb-client-tests/TcpProxy.cs @@ -0,0 +1,153 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Net; +using System.Net.Sockets; + +namespace net_questdb_client_tests; + +public sealed class TcpProxy : IDisposable +{ + private readonly string _backendHost; + private readonly int _backendPort; + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly object _connectionsLock = new(); + private readonly List<(TcpClient Client, TcpClient Backend)> _connections = new(); + private Task? _acceptTask; + private bool _disposed; + + public TcpProxy(string backendEndpoint) + { + var parts = backendEndpoint.Split(':', 2); + if (parts.Length != 2 || !int.TryParse(parts[1], out var port)) + { + throw new ArgumentException($"backend endpoint must be host:port, got `{backendEndpoint}`", + nameof(backendEndpoint)); + } + _backendHost = parts[0]; + _backendPort = port; + _listener = new TcpListener(IPAddress.Loopback, 0); + } + + public string LocalEndpoint { get; private set; } = string.Empty; + + public Task StartAsync() + { + if (_acceptTask != null) return Task.CompletedTask; + _listener.Start(); + var port = ((IPEndPoint)_listener.LocalEndpoint).Port; + LocalEndpoint = $"127.0.0.1:{port}"; + _acceptTask = AcceptLoopAsync(_cts.Token); + return Task.CompletedTask; + } + + public void KillAllConnections() + { + List<(TcpClient Client, TcpClient Backend)> snapshot; + lock (_connectionsLock) + { + snapshot = new List<(TcpClient, TcpClient)>(_connections); + _connections.Clear(); + } + + foreach (var (c, b) in snapshot) + { + try { c.Close(); } catch { } + try { b.Close(); } catch { } + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + try { _cts.Cancel(); } catch { } + KillAllConnections(); + try { _listener.Stop(); } catch { } + _cts.Dispose(); + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + catch (ObjectDisposedException) { return; } + catch { continue; } + + _ = HandleClientAsync(client, ct); + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + TcpClient? backend = null; + var registered = false; + try + { + backend = new TcpClient(); + await backend.ConnectAsync(_backendHost, _backendPort, ct).ConfigureAwait(false); + + lock (_connectionsLock) + { + _connections.Add((client, backend)); + registered = true; + } + + var clientToBackend = PipeAsync(client.GetStream(), backend.GetStream(), ct); + var backendToClient = PipeAsync(backend.GetStream(), client.GetStream(), ct); + await Task.WhenAny(clientToBackend, backendToClient).ConfigureAwait(false); + } + catch + { + } + finally + { + if (registered) + { + lock (_connectionsLock) { _connections.Remove((client, backend!)); } + } + try { client.Close(); } catch { } + try { backend?.Close(); } catch { } + } + } + + private static async Task PipeAsync(NetworkStream src, NetworkStream dst, CancellationToken ct) + { + var buf = new byte[8192]; + try + { + while (!ct.IsCancellationRequested) + { + var n = await src.ReadAsync(buf.AsMemory(), ct).ConfigureAwait(false); + if (n <= 0) return; + await dst.WriteAsync(buf.AsMemory(0, n), ct).ConfigureAwait(false); + } + } + catch { } + } +} diff --git a/src/net-questdb-client-tests/Utils/QwpTlsAuthTests.cs b/src/net-questdb-client-tests/Utils/QwpTlsAuthTests.cs new file mode 100644 index 00000000..6d6cddc9 --- /dev/null +++ b/src/net-questdb-client-tests/Utils/QwpTlsAuthTests.cs @@ -0,0 +1,191 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Utils; + +[TestFixture] +public class QwpTlsAuthTests +{ + [Test] + public void BuildAuthHeader_BasicEncodesUtf8Pair() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "alice", password: "s3cret", token: null); + Assert.That(header, Is.EqualTo("Basic " + Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes("alice:s3cret")))); + } + + [Test] + public void BuildAuthHeader_BasicHandlesNonAsciiCredentials() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "用户", password: "пароль", token: null); + var expected = "Basic " + Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes("用户:пароль")); + Assert.That(header, Is.EqualTo(expected)); + } + + [Test] + public void BuildAuthHeader_BearerToken() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: null, password: null, token: "tok"); + Assert.That(header, Is.EqualTo("Bearer tok")); + } + + [Test] + public void BuildAuthHeader_BasicTakesPrecedenceOverBearer() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "u", password: "p", token: "tok"); + Assert.That(header, Does.StartWith("Basic ")); + } + + [Test] + public void BuildAuthHeader_UsernameWithoutPassword_FallsThroughToToken() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "u", password: null, token: "tok"); + Assert.That(header, Is.EqualTo("Bearer tok")); + } + + [Test] + public void BuildAuthHeader_AllNull_ReturnsNull() + { + Assert.That(QwpTlsAuth.BuildAuthHeader(null, null, null), Is.Null); + } + + [Test] + public void BuildAuthHeader_AllEmptyStrings_TreatedAsUnset() + { + Assert.That(QwpTlsAuth.BuildAuthHeader("", "", ""), Is.Null); + } + + [Test] + public void BuildCertificateValidator_UnsafeOff_AlwaysTrueCallback() + { + using var validator = QwpTlsAuth.BuildCertificateValidator(TlsVerifyType.unsafe_off, null, null); + Assert.That(validator, Is.Not.Null); + var cb = validator!.Callback; + Assert.That(cb(null!, null, null!, System.Net.Security.SslPolicyErrors.RemoteCertificateChainErrors), Is.True); + Assert.That(cb(null!, null, null!, System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch), Is.True); + Assert.That(cb(null!, null, null!, System.Net.Security.SslPolicyErrors.None), Is.True); + } + + [Test] + public void BuildCertificateValidator_VerifyOn_NoCustomRoots_ReturnsNull() + { + var cb = QwpTlsAuth.BuildCertificateValidator(TlsVerifyType.on, null, null); + Assert.That(cb, Is.Null); + } + + [Test] + public void BuildCertificateValidator_VerifyOn_EmptyRoots_ReturnsNull() + { + var cb = QwpTlsAuth.BuildCertificateValidator(TlsVerifyType.on, "", ""); + Assert.That(cb, Is.Null); + } + + [Test] + public void BuildCertificateValidator_VerifyOn_WithCustomRoots_AcceptsChainedRejectsUnrelated() + { + using var ca = NewCertificateAuthority("CN=qwp-test-ca"); + using var leaf = NewLeafCertificate("CN=leaf.qwp.test", ca); + using var unrelated = NewSelfSigned("CN=stranger.qwp.test"); + + var pfxPath = Path.Combine(Path.GetTempPath(), "qwp-ca-" + Guid.NewGuid().ToString("N") + ".pfx"); +#pragma warning disable SYSLIB0057 + File.WriteAllBytes(pfxPath, ca.Export(X509ContentType.Pfx)); +#pragma warning restore SYSLIB0057 + try + { + using var validator = QwpTlsAuth.BuildCertificateValidator(TlsVerifyType.on, pfxPath, null); + Assert.That(validator, Is.Not.Null); + var cb = validator!.Callback; + + using (var chainForLeaf = new X509Chain()) + { + Assert.That( + cb(this, leaf, chainForLeaf, SslPolicyErrors.RemoteCertificateChainErrors), + Is.True, + "a cert chained to the pinned custom CA must validate"); + } + + using (var chainForStranger = new X509Chain()) + { + Assert.That( + cb(this, unrelated, chainForStranger, SslPolicyErrors.RemoteCertificateChainErrors), + Is.False, + "a cert not chained to the pinned CA must be rejected"); + } + + using (var chainForLeaf2 = new X509Chain()) + { + Assert.That( + cb(this, leaf, chainForLeaf2, SslPolicyErrors.RemoteCertificateNameMismatch), + Is.False, + "a name mismatch must be rejected even for a CA-chained cert"); + } + } + finally + { + try { File.Delete(pfxPath); } catch { } + } + } + + private static X509Certificate2 NewCertificateAuthority(string subject) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.DigitalSignature, true)); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + } + + private static X509Certificate2 NewLeafCertificate(string subject, X509Certificate2 issuer) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + var serial = new byte[8]; + RandomNumberGenerator.Fill(serial); + using var signed = req.Create(issuer, issuer.NotBefore, issuer.NotAfter, serial); + return signed.CopyWithPrivateKey(rsa); + } + + private static X509Certificate2 NewSelfSigned(string subject) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/src/net-questdb-client-tests/net-questdb-client-tests.csproj b/src/net-questdb-client-tests/net-questdb-client-tests.csproj index 030eb016..c4ca86fe 100644 --- a/src/net-questdb-client-tests/net-questdb-client-tests.csproj +++ b/src/net-questdb-client-tests/net-questdb-client-tests.csproj @@ -5,6 +5,8 @@ enable enable false + latest + true net6.0;net7.0;net8.0;net9.0;net10.0 diff --git a/src/net-questdb-client/Buffers/Buffer.cs b/src/net-questdb-client/Buffers/Buffer.cs index 9c17b045..bd7ec99e 100644 --- a/src/net-questdb-client/Buffers/Buffer.cs +++ b/src/net-questdb-client/Buffers/Buffer.cs @@ -38,16 +38,18 @@ public static class Buffer /// Maximum allowed length for names stored in the buffer. /// Maximum total buffer capacity. /// Protocol version that determines which concrete buffer implementation to create. + /// When true, timestamps are converted to UTC before encoding. /// An instance corresponding to the specified protocol version. /// Thrown when an unsupported protocol version is provided. - public static IBuffer Create(int bufferSize, int maxNameLen, int maxBufSize, ProtocolVersion version) + public static IBuffer Create(int bufferSize, int maxNameLen, int maxBufSize, ProtocolVersion version, + bool convertLocalToUtc = false) { return version switch { - ProtocolVersion.V1 => new BufferV1(bufferSize, maxNameLen, maxBufSize), - ProtocolVersion.V2 => new BufferV2(bufferSize, maxNameLen, maxBufSize), - ProtocolVersion.V3 => new BufferV3(bufferSize, maxNameLen, maxBufSize), - ProtocolVersion.Auto => new BufferV3(bufferSize, maxNameLen, maxBufSize), + ProtocolVersion.V1 => new BufferV1(bufferSize, maxNameLen, maxBufSize, convertLocalToUtc), + ProtocolVersion.V2 => new BufferV2(bufferSize, maxNameLen, maxBufSize, convertLocalToUtc), + ProtocolVersion.V3 => new BufferV3(bufferSize, maxNameLen, maxBufSize, convertLocalToUtc), + ProtocolVersion.Auto => new BufferV3(bufferSize, maxNameLen, maxBufSize, convertLocalToUtc), _ => throw new NotImplementedException(), }; } diff --git a/src/net-questdb-client/Buffers/BufferV1.cs b/src/net-questdb-client/Buffers/BufferV1.cs index 7a61a0c6..1e4c813d 100644 --- a/src/net-questdb-client/Buffers/BufferV1.cs +++ b/src/net-questdb-client/Buffers/BufferV1.cs @@ -35,6 +35,7 @@ public class BufferV1 : IBuffer { private static readonly long EpochTicks = new DateTime(1970, 1, 1).Ticks; private readonly List<(byte[] Buffer, int Length)> _buffers = new(); + private readonly bool _convertLocalToUtc; private readonly int _maxBufSize; private readonly int _maxNameLen; private int _currentBufferIndex; @@ -53,12 +54,14 @@ public class BufferV1 : IBuffer /// Initial size of each buffer chunk, in bytes. /// Maximum allowed UTF-8 byte length for table and column names. /// Maximum total buffer size across all chunks, in bytes. - public BufferV1(int bufferSize, int maxNameLen, int maxBufSize) + /// When true, timestamps are converted to UTC before encoding. + public BufferV1(int bufferSize, int maxNameLen, int maxBufSize, bool convertLocalToUtc = false) { Chunk = new byte[bufferSize]; _buffers.Add((Chunk, 0)); _maxNameLen = maxNameLen; _maxBufSize = maxBufSize; + _convertLocalToUtc = convertLocalToUtc; } /// @@ -113,11 +116,16 @@ public void AtNow() /// public void At(DateTime timestamp) { - var epoch = timestamp.Ticks - EpochTicks; + var utc = NormaliseToUtc(timestamp); + var epoch = utc.Ticks - EpochTicks; PutAscii(' ').Put(epoch * 100); FinishLine(); } + private DateTime NormaliseToUtc(DateTime value) => _convertLocalToUtc && value.Kind == DateTimeKind.Local + ? value.ToUniversalTime() + : value; + /// public void At(DateTimeOffset timestamp) { @@ -354,7 +362,8 @@ public IBuffer Column(ReadOnlySpan name, DateTime timestamp) Table(_currentTableName); } - var epoch = timestamp.Ticks - EpochTicks; + var utc = NormaliseToUtc(timestamp); + var epoch = utc.Ticks - EpochTicks; Column(name).Put(epoch * 100).PutAscii('n'); return this; } diff --git a/src/net-questdb-client/Buffers/BufferV2.cs b/src/net-questdb-client/Buffers/BufferV2.cs index ac3a23fc..8adb68fc 100644 --- a/src/net-questdb-client/Buffers/BufferV2.cs +++ b/src/net-questdb-client/Buffers/BufferV2.cs @@ -38,7 +38,9 @@ public class BufferV2 : BufferV1 /// Initial size of the internal write buffer, in bytes. /// Maximum allowed length for column names, in characters. /// Maximum allowed internal buffer size, in bytes. - public BufferV2(int bufferSize, int maxNameLen, int maxBufSize) : base(bufferSize, maxNameLen, maxBufSize) + /// When true, timestamps are converted to UTC before encoding. + public BufferV2(int bufferSize, int maxNameLen, int maxBufSize, bool convertLocalToUtc = false) + : base(bufferSize, maxNameLen, maxBufSize, convertLocalToUtc) { } diff --git a/src/net-questdb-client/Buffers/BufferV3.cs b/src/net-questdb-client/Buffers/BufferV3.cs index 3e981919..66c573cc 100644 --- a/src/net-questdb-client/Buffers/BufferV3.cs +++ b/src/net-questdb-client/Buffers/BufferV3.cs @@ -49,7 +49,9 @@ public class BufferV3 : BufferV2 /// Initial size of the internal write buffer, in bytes. /// Maximum allowed length for column names, in characters. /// Maximum allowed internal buffer size, in bytes. - public BufferV3(int bufferSize, int maxNameLen, int maxBufSize) : base(bufferSize, maxNameLen, maxBufSize) + /// When true, timestamps are converted to UTC before encoding. + public BufferV3(int bufferSize, int maxNameLen, int maxBufSize, bool convertLocalToUtc = false) + : base(bufferSize, maxNameLen, maxBufSize, convertLocalToUtc) { } diff --git a/src/net-questdb-client/Enums/CompressionType.cs b/src/net-questdb-client/Enums/CompressionType.cs new file mode 100644 index 00000000..f6c441bb --- /dev/null +++ b/src/net-questdb-client/Enums/CompressionType.cs @@ -0,0 +1,38 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// QWP egress wire compression negotiation modes. +public enum CompressionType +{ + /// Advertise zstd, raw; server picks zstd if supported, else raw. + auto, + + /// No compression. Sent as raw in the upgrade header. + raw, + + /// Demand zstd; the server falls back to raw per-batch when raw is smaller. + zstd, +} diff --git a/src/net-questdb-client/Enums/ErrorCode.cs b/src/net-questdb-client/Enums/ErrorCode.cs index 3beb61c6..a788b0f2 100644 --- a/src/net-questdb-client/Enums/ErrorCode.cs +++ b/src/net-questdb-client/Enums/ErrorCode.cs @@ -97,4 +97,20 @@ public enum ErrorCode /// Bad configuration. /// InvalidArrayShapeError, + + /// + /// A structurally corrupt or malformed wire frame was received, or the + /// server closed the WebSocket with a protocol-violation code + /// (RFC 6455 1002 / 1003 / 1007 / 1008 / 1009 / 1010). Terminal; this is + /// not a connectivity fault, so the cursor engine / failover must not + /// reconnect or re-issue the query. + /// + ProtocolViolation, + + /// + /// request_durable_ack=on was set but the server did not echo + /// X-QWP-Durable-Ack: enabled. Terminal — without it the SF log would + /// grow unbounded waiting for ack frames that will never arrive. + /// + DurableAckNotSupported, } \ No newline at end of file diff --git a/src/net-questdb-client/Enums/InitialConnectMode.cs b/src/net-questdb-client/Enums/InitialConnectMode.cs new file mode 100644 index 00000000..be5f7add --- /dev/null +++ b/src/net-questdb-client/Enums/InitialConnectMode.cs @@ -0,0 +1,48 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// First-connect retry policy for the SF cursor engine. +/// +/// +/// — try the configured address list once; throw at construction +/// if every host fails. Default. Surfaces "couldn't reach server" eagerly. +/// — block the constructor; retry with exponential backoff until +/// one host accepts the upgrade or reconnect_max_duration_millis exhausts. +/// — return from the constructor immediately; the background +/// I/O thread drives the reconnect loop. Subsequent append calls buffer to the +/// SF dir without blocking on connect. Intended for unattended producers where the +/// dir may already carry segments queued from a prior process and the server may +/// come up later. +/// +/// The connect string accepts off/false, on/true/sync, +/// and async; sync is an alias for on. +/// +public enum InitialConnectMode +{ + off, + on, + async, +} diff --git a/src/net-questdb-client/Enums/ProtocolType.cs b/src/net-questdb-client/Enums/ProtocolType.cs index e16bfa53..20d8449c 100644 --- a/src/net-questdb-client/Enums/ProtocolType.cs +++ b/src/net-questdb-client/Enums/ProtocolType.cs @@ -53,4 +53,14 @@ public enum ProtocolType /// HTTP transport with TLS. /// https, + + /// + /// WebSocket transport carrying the QWP columnar binary ingest protocol. + /// + ws, + + /// + /// WebSocket transport with TLS. + /// + wss, } \ No newline at end of file diff --git a/src/net-questdb-client/Enums/QwpEgressMsgKind.cs b/src/net-questdb-client/Enums/QwpEgressMsgKind.cs new file mode 100644 index 00000000..8e346d84 --- /dev/null +++ b/src/net-questdb-client/Enums/QwpEgressMsgKind.cs @@ -0,0 +1,39 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// QWP egress message kind discriminator (first byte of every egress payload). +public enum QwpEgressMsgKind : byte +{ + QueryRequest = 0x10, + ResultBatch = 0x11, + ResultEnd = 0x12, + QueryError = 0x13, + Cancel = 0x14, + Credit = 0x15, + ExecDone = 0x16, + CacheReset = 0x17, + ServerInfo = 0x18, +} diff --git a/src/net-questdb-client/Enums/QwpOpType.cs b/src/net-questdb-client/Enums/QwpOpType.cs new file mode 100644 index 00000000..c79a5343 --- /dev/null +++ b/src/net-questdb-client/Enums/QwpOpType.cs @@ -0,0 +1,142 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// +/// Statement kind reported in the QWP EXEC_DONE frame for non-row-returning queries. +/// Mirrors the server's CompiledQuery type constants. Backed by to match the +/// single-byte wire encoding; an unrecognised wire value casts to an unnamed member rather than failing. +/// Values 15 and 16 are intentionally unused (a gap in the server enum). +/// +public enum QwpOpType : byte +{ + /// No statement; the server's NONE sentinel. + None = 0, + + /// SELECT query. Row-returning selects normally terminate via the batch stream, not EXEC_DONE. + Select = 1, + + /// INSERT of literal row values. + Insert = 2, + + /// TRUNCATE TABLE. + Truncate = 3, + + /// ALTER TABLE. + Alter = 4, + + /// Internal table repair (recovery of an interrupted operation). + Repair = 5, + + /// SET of a session parameter. + Set = 6, + + /// DROP of a table or other database object. + Drop = 7, + + /// Pseudo-SELECT statements such as COPY. + PseudoSelect = 8, + + /// CREATE TABLE. + CreateTable = 9, + + /// INSERT INTO ... SELECT. + InsertAsSelect = 10, + + /// COPY bulk import/export. + CopyRemote = 11, + + /// RENAME TABLE. + RenameTable = 12, + + /// BACKUP of the database or a table. + BackupDatabase = 13, + + /// UPDATE. + Update = 14, + + /// VACUUM TABLE. + Vacuum = 17, + + /// BEGIN a transaction. + Begin = 18, + + /// COMMIT. + Commit = 19, + + /// ROLLBACK. + Rollback = 20, + + /// CREATE TABLE ... AS SELECT. + CreateTableAsSelect = 21, + + /// CHECKPOINT CREATE (enter snapshot mode). + CheckpointCreate = 22, + + /// CHECKPOINT RELEASE (leave snapshot mode). + CheckpointRelease = 23, + + /// DEALLOCATE a prepared statement. + Deallocate = 24, + + /// EXPLAIN. + Explain = 25, + + /// ALTER TABLE ... RESUME WAL. + TableResume = 26, + + /// ALTER TABLE ... SET TYPE (convert between WAL and non-WAL). + TableSetType = 27, + + /// CREATE USER. + CreateUser = 28, + + /// ALTER USER. + AlterUser = 29, + + /// CANCEL QUERY. + CancelQuery = 30, + + /// ALTER TABLE ... SUSPEND WAL. + TableSuspend = 31, + + /// CREATE MATERIALIZED VIEW. + CreateMatView = 32, + + /// REFRESH MATERIALIZED VIEW. + RefreshMatView = 33, + + /// CREATE VIEW. + CreateView = 34, + + /// Internal view (re)compilation. + CompileView = 35, + + /// ALTER VIEW. + AlterView = 36, + + /// ALTER ... storage policy (tiered storage). + AlterStoragePolicy = 37, +} diff --git a/src/net-questdb-client/Enums/QwpRole.cs b/src/net-questdb-client/Enums/QwpRole.cs new file mode 100644 index 00000000..0ca68668 --- /dev/null +++ b/src/net-questdb-client/Enums/QwpRole.cs @@ -0,0 +1,49 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// +/// Server role advertised in the QWP SERVER_INFO frame. Backed by to match the +/// single-byte wire encoding; an unrecognised wire value casts to an unnamed member rather than failing. +/// +public enum QwpRole : byte +{ + /// Single, non-replicated node. + Standalone = 0x00, + + /// Primary node in a replicated cluster. + Primary = 0x01, + + /// Read replica. + Replica = 0x02, + + /// Former primary still catching up after a failover; transiently unavailable for writes. + PrimaryCatchup = 0x03, + + /// + /// Server value does not map to any known category + /// + Undefined = byte.MaxValue +} diff --git a/src/net-questdb-client/Enums/QwpStatusCode.cs b/src/net-questdb-client/Enums/QwpStatusCode.cs new file mode 100644 index 00000000..cbc2ab1f --- /dev/null +++ b/src/net-questdb-client/Enums/QwpStatusCode.cs @@ -0,0 +1,61 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// +/// QWP server response status codes. +/// +public enum QwpStatusCode : byte +{ + /// Cumulative ACK. Sequence in the frame is the highest acknowledged batch. + Ok = 0x00, + + /// + /// Object-store durability watermark. Per-table; the frame carries no batch sequence. + /// Only emitted when the client requested durable acks during the upgrade. + /// + DurableAck = 0x02, + + /// Column type incompatible with the existing table schema. + SchemaMismatch = 0x03, + + /// Malformed message. + ParseError = 0x05, + + /// Server-side internal error. + InternalError = 0x06, + + /// Authorization failure. + SecurityError = 0x08, + + /// Write failure (for example, table not accepting writes). + WriteError = 0x09, + + /// Query terminated in response to a CANCEL frame. + Cancelled = 0x0A, + + /// A protocol limit was hit. + LimitExceeded = 0x0B, +} diff --git a/src/net-questdb-client/Enums/QwpTypeCode.cs b/src/net-questdb-client/Enums/QwpTypeCode.cs new file mode 100644 index 00000000..7b1bba4a --- /dev/null +++ b/src/net-questdb-client/Enums/QwpTypeCode.cs @@ -0,0 +1,105 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// +/// QWP wire-format column type codes. +/// +/// +/// The legacy STRING (0x08) type is intentionally absent: the client emits +/// for all string columns. Both shapes are byte-compatible on +/// the wire; the client simply standardises on the newer code. +/// +public enum QwpTypeCode : byte +{ + /// Bit-packed boolean. + Boolean = 0x01, + + /// Signed 8-bit integer. + Byte = 0x02, + + /// Signed 16-bit integer. + Short = 0x03, + + /// Signed 32-bit integer. + Int = 0x04, + + /// Signed 64-bit integer. + Long = 0x05, + + /// IEEE 754 single-precision float. + Float = 0x06, + + /// IEEE 754 double-precision float. + Double = 0x07, + + /// Dictionary-encoded string. Uses delta dictionary mode on WebSocket. + Symbol = 0x09, + + /// Microseconds since the Unix epoch. + Timestamp = 0x0A, + + /// Milliseconds since the Unix epoch. + Date = 0x0B, + + /// RFC 4122 UUID; 16 bytes (low 8 then high 8). + Uuid = 0x0C, + + /// 256-bit unsigned integer; 32 bytes little-endian. + Long256 = 0x0D, + + /// Geospatial hash with explicit precision. + Geohash = 0x0E, + + /// Length-prefixed UTF-8 string with auxiliary offset storage. + Varchar = 0x0F, + + /// Nanoseconds since the Unix epoch. + TimestampNanos = 0x10, + + /// N-dimensional double array. + DoubleArray = 0x11, + + /// N-dimensional long array. + LongArray = 0x12, + + /// 64-bit decimal (18-digit precision). + Decimal64 = 0x13, + + /// 128-bit decimal (38-digit precision). + Decimal128 = 0x14, + + /// 256-bit decimal (77-digit precision). + Decimal256 = 0x15, + + /// UTF-16 code unit (2 bytes little-endian). + Char = 0x16, + + /// Opaque byte sequence; same wire layout as VARCHAR but no UTF-8 contract. + Binary = 0x17, + + /// IPv4 address; same wire layout as INT (4 bytes little-endian). + IPv4 = 0x18, +} diff --git a/src/net-questdb-client/Enums/SenderErrorCategory.cs b/src/net-questdb-client/Enums/SenderErrorCategory.cs new file mode 100644 index 00000000..c3db07a4 --- /dev/null +++ b/src/net-questdb-client/Enums/SenderErrorCategory.cs @@ -0,0 +1,54 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// +/// Server-distinguishable rejection categories. Aligned 1:1 with the stable QWP +/// wire status bytes for ingress, plus for +/// WebSocket-level close frames and for forward compatibility. +/// +public enum SenderErrorCategory +{ + /// Schema mismatch (column missing, type clash, NOT NULL violated, no such table). Wire 0x03. + SchemaMismatch, + + /// QWP-level malformed payload — most likely a client bug. Wire 0x05. + ParseError, + + /// Server-side fault, catch-all. Wire 0x06. + InternalError, + + /// Authentication or authorization failure. Wire 0x08. + SecurityError, + + /// Non-critical Cairo error, table not accepting writes. Wire 0x09. + WriteError, + + /// WebSocket-layer close frame with a terminal code. + ProtocolViolation, + + /// Status byte the client does not recognize — forward compatibility for new server codes. + Unknown, +} diff --git a/src/net-questdb-client/Enums/SenderErrorPolicy.cs b/src/net-questdb-client/Enums/SenderErrorPolicy.cs new file mode 100644 index 00000000..a6b8f5ec --- /dev/null +++ b/src/net-questdb-client/Enums/SenderErrorPolicy.cs @@ -0,0 +1,46 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// +/// Policy applied by the client when a fires. +/// and +/// are forced . +/// +public enum SenderErrorPolicy +{ + /// + /// Drop the rejected batch from the SF disk store (advance the ack watermark past it) + /// and continue draining subsequent batches. The data is lost from the sender's perspective. + /// + DropAndContinue, + + /// + /// Latch the error as terminal. The next producer-thread API call throws + /// . The sender does not drain further + /// until the caller closes and rebuilds it. + /// + Halt, +} diff --git a/src/net-questdb-client/Enums/TargetType.cs b/src/net-questdb-client/Enums/TargetType.cs new file mode 100644 index 00000000..edfefeea --- /dev/null +++ b/src/net-questdb-client/Enums/TargetType.cs @@ -0,0 +1,41 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Enums; + +/// Replication-role filter applied during multi-endpoint connect(). +/// +/// Match table: +/// +/// — accepts STANDALONE, PRIMARY, PRIMARY_CATCHUP, REPLICA. +/// — accepts STANDALONE, PRIMARY, PRIMARY_CATCHUP. +/// — accepts REPLICA only. +/// +/// +public enum TargetType +{ + any, + primary, + replica, +} diff --git a/src/net-questdb-client/QueryClient.cs b/src/net-questdb-client/QueryClient.cs new file mode 100644 index 00000000..1d3de0ee --- /dev/null +++ b/src/net-questdb-client/QueryClient.cs @@ -0,0 +1,98 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Qwp.Query; +using QuestDB.Senders; +using QuestDB.Utils; + +namespace QuestDB; + +/// Factory for the QWP egress (query) client. Mirrors 's shape. +public static class QueryClient +{ + /// + /// Builds a query client from a connect-string (e.g. ws::addr=localhost:9000;target=any;). + /// Prefer from async code. + /// + public static IQwpQueryClient New(string connectionString) + { + ArgumentNullException.ThrowIfNull(connectionString); + return New(new QueryOptions(connectionString)); + } + + /// + /// Creates an from the connect-string stored in the + /// QDB_CLIENT_CONF environment variable. Convenience for cloud and 12-factor + /// deployments. + /// + /// Thrown when QDB_CLIENT_CONF is unset or blank. + public static IQwpQueryClient FromEnv() + { + var confStr = Environment.GetEnvironmentVariable(Sender.EnvConfStr); + if (string.IsNullOrWhiteSpace(confStr)) + { + throw new IngressError(ErrorCode.ConfigError, + $"{Sender.EnvConfStr} environment variable is not set"); + } + return New(confStr); + } + + /// Builds a query client from a programmatically configured . + public static IQwpQueryClient New(QueryOptions options) + { + ArgumentNullException.ThrowIfNull(options); +#if NET7_0_OR_GREATER + // Threadpool hop drops captured SyncContext so sync-over-async can't deadlock on UI / classic ASP.NET. + return Task.Run(() => NewAsync(options, CancellationToken.None)).GetAwaiter().GetResult(); +#else + throw new IngressError(ErrorCode.ConfigError, + "QWP egress (query) client requires .NET 7 or newer"); +#endif + } + +#if NET7_0_OR_GREATER + /// Async factory; preferred when the caller is already on an async path. + public static Task NewAsync(string connectionString, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(connectionString); + return NewAsync(new QueryOptions(connectionString), ct); + } + + /// + public static async Task NewAsync(QueryOptions options, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(options); + options.EnsureValid(); + + if (options.protocol != ProtocolType.ws && options.protocol != ProtocolType.wss) + { + throw new IngressError(ErrorCode.ConfigError, + $"egress requires ws:: or wss:: scheme, got {options.protocol}"); + } + + return await QwpQueryWebSocketClient.CreateAsync(options, ct).ConfigureAwait(false); + } +#endif +} diff --git a/src/net-questdb-client/Qwp/Query/QueryOptions.cs b/src/net-questdb-client/Qwp/Query/QueryOptions.cs new file mode 100644 index 00000000..e364b0f6 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QueryOptions.cs @@ -0,0 +1,565 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Data.Common; +using System.Globalization; +using QuestDB.Enums; +using QuestDB.Utils; + +// ReSharper disable InconsistentNaming +// ReSharper disable PropertyCanBeMadeInitOnly.Global + +namespace QuestDB.Qwp.Query; + +/// +/// Configuration for a QueryClient egress connection. Either build programmatically +/// and call QueryClient.New(options), or parse a connection string with +/// / QueryClient.New(connStr). +/// +/// +/// Disjoint from on purpose — ingress and egress speak different +/// wire protocols on different endpoints, and bleeding the two together (one bag of all +/// possible knobs) makes both halves harder to reason about. +/// +public sealed class QueryOptions +{ + private static readonly HashSet KeySet = BuildKeySet(); + + /// + /// Legacy ILP HTTP/TCP keys explicitly rejected on the QWP/WebSocket egress path. They are + /// absent from the QWP connect-string vocabulary (connect-string.md Key index), so they are + /// not in either; the parse loop checks this list first to return a + /// transport-specific message instead of the generic "invalid property". + /// + private static readonly string[] IlpHttpOnlyKeys = + { + "protocol_version", + "request_min_throughput", + "request_timeout", + "retry_timeout", + "gzip", + "pool_timeout", + "own_socket", + "token_x", + "token_y", + "auth_timeout", + }; + + private static HashSet BuildKeySet() + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var k in QwpConnectStringKeys.Shared) keys.Add(k); + foreach (var k in QwpConnectStringKeys.EgressOnly) keys.Add(k); + // The connect string is one shared input; accept (and ignore) the ingress sender's keys. + foreach (var k in QwpConnectStringKeys.IngressOnly) keys.Add(k); + return keys; + } + + private List _addresses = new(); + private string[]? _singletonAddrCache; + private string? _singletonAddrCacheKey; + private string _addr = "localhost:9000"; + + /// Constructs an instance with default values; mutate properties before passing to QueryClient.New. + public QueryOptions() + { + } + + /// Parses a ws:: / wss:: connection string and validates the resulting option set. + public QueryOptions(string connStr) + { + ArgumentNullException.ThrowIfNull(connStr); + Parse(connStr); + EnsureValid(); + } + + /// Wire protocol; only and are accepted on the egress side. + public ProtocolType protocol { get; set; } = ProtocolType.ws; + + /// + /// Default host:port, or comma-separated multi-address list. When the value contains + /// a comma the entries are split into for failover (matches the + /// addr=h1:p1,h2:p2 connstring syntax). + /// + public string addr + { + get => _addr; + set => SetAddr(value); + } + + private void SetAddr(string value) + { + ArgumentNullException.ThrowIfNull(value); + if (value.IndexOf(',') < 0) + { + _addr = value; + _addresses.Clear(); + return; + } + + var parts = value.Split(','); + var list = new List(parts.Length); + foreach (var piece in parts) + { + var trimmed = piece.Trim(); + if (trimmed.Length == 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"empty entry in comma-separated `addr={value}`"); + } + list.Add(trimmed); + } + + _addresses = list; + _addr = list[0]; + } + + /// Failover address list; falls back to a single-element list of when no multi-address connstring keys were provided. + public IReadOnlyList addresses + { + get + { + if (_addresses.Count > 0) return _addresses; + if (_singletonAddrCache is null || !ReferenceEquals(_singletonAddrCacheKey, addr)) + { + _singletonAddrCache = new[] { addr }; + _singletonAddrCacheKey = addr; + } + return _singletonAddrCache; + } + } + + /// Number of addresses available for failover; 1 when only is set. + public int AddressCount => _addresses.Count == 0 ? 1 : _addresses.Count; + + /// HTTP path used for the WebSocket upgrade; defaults to . + public string path { get; set; } = QwpConstants.ReadPath; + + /// Basic-auth username; pair with . + public string? username { get; set; } + /// Basic-auth password; pair with . + public string? password { get; set; } + /// Bearer token; mutually exclusive with username/password. + public string? token { get; set; } + + /// TLS hostname/cert verification policy for wss::. + public TlsVerifyType tls_verify { get; set; } = TlsVerifyType.on; + /// Optional path to a PFX bundle pinning custom CA roots. + public string? tls_roots { get; set; } + /// Optional password for . + public string? tls_roots_password { get; set; } + + /// Frame-level compression policy applied to query payloads. + public CompressionType compression { get; set; } = CompressionType.raw; + /// Per-codec compression level; meaningful when is not none. + public int compression_level { get; set; } = 1; + + /// Server-side target preference forwarded with the upgrade request. + public TargetType target { get; set; } = TargetType.any; + /// Whether to retry against alternative addresses on connect failure. + public bool failover { get; set; } = true; + /// Cap on consecutive failover attempts before surfacing the underlying error. + public int failover_max_attempts { get; set; } = 8; + /// Initial back-off between failover attempts; doubled per attempt up to . + public TimeSpan failover_backoff_initial_ms { get; set; } = TimeSpan.FromMilliseconds(50); + /// Cap on the failover back-off interval. + public TimeSpan failover_backoff_max_ms { get; set; } = TimeSpan.FromMilliseconds(1000); + /// Total wall-clock budget for the failover loop across all attempts; = unbounded. Whichever of or this fires first ends the loop. + public TimeSpan failover_max_duration_ms { get; set; } = TimeSpan.FromSeconds(30); + /// Per-endpoint timeout applied to the WebSocket upgrade (TCP+TLS+HTTP+SERVER_INFO). Without this, an unreachable address can block on OS-level TCP timeouts (~21s Linux, ~75s macOS). + public TimeSpan auth_timeout_ms { get; set; } = TimeSpan.FromSeconds(15); + + /// + /// Client zone identifier (opaque, case-insensitive; e.g. eu-west-1a). When set with + /// target=any or target=replica, prefers endpoints whose server-advertised + /// zone_id matches. Ignored when target=primary (writers follow the master across + /// zones). + /// + public string? zone { get; set; } + + /// Optional cap on rows per decoded batch; 0 defers to the server's batch size. + public int max_batch_rows { get; set; } + /// Optional client identifier echoed in upgrade headers; useful for server-side logs. + public string? client_id { get; set; } + + /// + /// Per-query byte budget the server may emit before pausing for a CREDIT frame. + /// 0 = unbounded. + /// + public long initial_credit { get; set; } + + /// + /// Validates the option set; called automatically by the connstring constructor and by the + /// query client before opening a connection. Programmatic constructions must call this + /// manually if they want validation up front. + /// + public void EnsureValid() + { + if (protocol != ProtocolType.ws && protocol != ProtocolType.wss) + { + throw new IngressError(ErrorCode.ConfigError, + $"egress protocol must be ws or wss, got {protocol}"); + } + + ValidateAddress(); + ValidateAuthCombination(); + ValidateTls(); + ValidateCompressionLevel(); + ValidateNumericRanges(); + ValidateInitialCredit(); + RejectControlChars(nameof(username), username); + RejectControlChars(nameof(password), password); + RejectControlChars(nameof(token), token); + RejectControlChars(nameof(client_id), client_id); + RejectControlChars(nameof(path), path); + RejectControlChars(nameof(zone), zone); + } + + private void Parse(string connStr) + { + var sep = connStr.IndexOf("::", StringComparison.Ordinal); + if (sep < 0) + { + throw new IngressError(ErrorCode.ConfigError, + "connection string must start with `ws::` or `wss::`"); + } + + var schemeText = connStr.Substring(0, sep); + if (!Enum.TryParse(schemeText, out ProtocolType scheme) + || (scheme != ProtocolType.ws && scheme != ProtocolType.wss)) + { + throw new IngressError(ErrorCode.ConfigError, + $"egress connection string must use the ws:: or wss:: scheme, got `{schemeText}::`"); + } + + protocol = scheme; + var paramString = connStr.Substring(sep + 2); + + _addresses.Clear(); + foreach (var entry in paramString.Split(';')) + { + if (string.IsNullOrWhiteSpace(entry)) continue; + var eq = entry.IndexOf('='); + if (eq < 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed config entry `{entry.Trim()}`; expected `key=value`"); + } + + var key = entry.Substring(0, eq).Trim(); + var value = entry.Substring(eq + 1).Trim(); + if (key.Length == 0 || value.Length == 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed config entry `{entry.Trim()}`; key and value must both be non-empty"); + } + + if (string.Equals(key, "addr", StringComparison.OrdinalIgnoreCase)) + { + foreach (var piece in value.Split(',')) + { + var trimmed = piece.Trim(); + if (trimmed.Length == 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"empty entry in comma-separated `addr={value}`"); + } + _addresses.Add(trimmed); + } + } + } + + var builder = new DbConnectionStringBuilder { ConnectionString = paramString }; + foreach (string key in builder.Keys) + { + if (IlpHttpOnlyKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{key}` is not supported for QWP/WebSocket transport"); + } + if (!KeySet.Contains(key)) + { + throw new IngressError(ErrorCode.ConfigError, $"invalid property: `{key}`"); + } + } + + if (_addresses.Count > 0) + { + _addr = _addresses[0]; + } + else if (builder.TryGetValue("addr", out var addrVal)) + { + _addr = (string)addrVal; + _addresses.Add(_addr); + } + + path = ReadStringOr(builder, "path", QwpConstants.ReadPath)!; + username = ReadString(builder, "username"); + password = ReadString(builder, "password"); + token = ReadString(builder, "token"); + client_id = ReadString(builder, "client_id"); + + tls_verify = ReadEnum(builder, "tls_verify", TlsVerifyType.on); + tls_roots = ReadString(builder, "tls_roots"); + tls_roots_password = ReadString(builder, "tls_roots_password"); + + compression = ParseCompression(builder); + compression_level = ReadInt(builder, "compression_level", 1); + + target = ReadEnum(builder, "target", TargetType.any); + failover = ReadBoolOnOff(builder, "failover", true); + failover_max_attempts = ReadInt(builder, "failover_max_attempts", 8); + failover_backoff_initial_ms = TimeSpan.FromMilliseconds( + ReadInt(builder, "failover_backoff_initial_ms", 50)); + failover_backoff_max_ms = TimeSpan.FromMilliseconds( + ReadInt(builder, "failover_backoff_max_ms", 1000)); + failover_max_duration_ms = TimeSpan.FromMilliseconds( + ReadInt(builder, "failover_max_duration_ms", 30000)); + auth_timeout_ms = TimeSpan.FromMilliseconds( + ReadInt(builder, "auth_timeout_ms", 15000)); + zone = ReadString(builder, "zone"); + + if (builder.ContainsKey("max_batch_rows")) + { + var v = ReadInt(builder, "max_batch_rows", 0); + if (v < 1 || v > 1_048_576) + { + throw new IngressError(ErrorCode.ConfigError, + $"`max_batch_rows` must be in [1, 1048576] (omit the key for server default), got {v}"); + } + max_batch_rows = v; + } + + if (builder.TryGetValue("initial_credit", out var icVal)) + { + var s = (string)icVal; + if (!long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ic)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`initial_credit` must be an integer, got `{s}`"); + } + initial_credit = ic; + } + } + + private void ValidateAddress() + { + if (string.IsNullOrEmpty(addr)) + { + throw new IngressError(ErrorCode.ConfigError, "`addr` is required"); + } + } + + private void ValidateAuthCombination() + { + var hasUsername = !string.IsNullOrEmpty(username); + var hasPassword = !string.IsNullOrEmpty(password); + var hasToken = !string.IsNullOrEmpty(token); + + if ((hasUsername || hasPassword) && hasToken) + { + throw new IngressError(ErrorCode.ConfigError, + "`username`/`password` and `token` are mutually exclusive"); + } + + if (hasUsername != hasPassword) + { + throw new IngressError(ErrorCode.ConfigError, + "`username` and `password` must be set together for HTTP Basic auth"); + } + } + + private void ValidateTls() + { + if (!string.IsNullOrEmpty(tls_roots_password) && string.IsNullOrEmpty(tls_roots)) + { + throw new IngressError(ErrorCode.ConfigError, + "`tls_roots_password` requires `tls_roots`"); + } + + if (protocol == ProtocolType.ws + && (tls_verify == TlsVerifyType.unsafe_off || !string.IsNullOrEmpty(tls_roots))) + { + throw new IngressError(ErrorCode.ConfigError, + "`tls_verify` and `tls_roots` are only supported with the wss:: scheme"); + } + } + + private void ValidateCompressionLevel() + { + if (compression == CompressionType.raw) return; + if (compression_level < QwpConstants.ZstdLevelMin || compression_level > QwpConstants.ZstdLevelMax) + { + throw new IngressError(ErrorCode.ConfigError, + $"`compression_level` must be in [{QwpConstants.ZstdLevelMin}, {QwpConstants.ZstdLevelMax}], got {compression_level}"); + } + } + + private void ValidateInitialCredit() + { + if (initial_credit < 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`initial_credit` must be >= 0 (0 = unbounded), got {initial_credit}"); + } + } + + private void ValidateNumericRanges() + { + // The failover loop sleeps via Task.Delay (shared QwpReconnectPolicy), which throws once the + // delay exceeds ~int.MaxValue ms (~24.8 days). The config-string path is int-parsed so it can't + // express a larger value; the programmatic TimeSpan setters can, so reject them here rather than + // let the failover loop fault with a raw ArgumentOutOfRangeException. + const long maxBackoffMillis = int.MaxValue; + + if (failover_max_attempts < 1) + { + throw new IngressError(ErrorCode.ConfigError, + $"`failover_max_attempts` must be >= 1, got {failover_max_attempts}"); + } + + if (failover_backoff_initial_ms <= TimeSpan.Zero) + { + throw new IngressError(ErrorCode.ConfigError, + "`failover_backoff_initial_ms` must be positive"); + } + + if (failover_backoff_max_ms <= TimeSpan.Zero) + { + throw new IngressError(ErrorCode.ConfigError, + "`failover_backoff_max_ms` must be positive"); + } + + if (failover_backoff_initial_ms > failover_backoff_max_ms) + { + throw new IngressError(ErrorCode.ConfigError, + "`failover_backoff_initial_ms` must be <= `failover_backoff_max_ms`"); + } + + if (failover_max_duration_ms < TimeSpan.Zero) + { + throw new IngressError(ErrorCode.ConfigError, + "`failover_max_duration_ms` must be non-negative (0 = unbounded)"); + } + + if (failover_backoff_initial_ms.TotalMilliseconds > maxBackoffMillis) + { + throw new IngressError(ErrorCode.ConfigError, + $"`failover_backoff_initial_ms` must be <= {maxBackoffMillis}ms (~24.8 days)"); + } + + if (failover_backoff_max_ms.TotalMilliseconds > maxBackoffMillis) + { + throw new IngressError(ErrorCode.ConfigError, + $"`failover_backoff_max_ms` must be <= {maxBackoffMillis}ms (~24.8 days)"); + } + + if (failover_max_duration_ms.TotalMilliseconds > maxBackoffMillis) + { + throw new IngressError(ErrorCode.ConfigError, + $"`failover_max_duration_ms` must be <= {maxBackoffMillis}ms (~24.8 days)"); + } + + if (auth_timeout_ms <= TimeSpan.Zero) + { + throw new IngressError(ErrorCode.ConfigError, + "`auth_timeout_ms` must be positive"); + } + + if (max_batch_rows < 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`max_batch_rows` must be >= 0 (0 = server default), got {max_batch_rows}"); + } + } + + private static void RejectControlChars(string name, string? value) + { + if (string.IsNullOrEmpty(value)) return; + foreach (var c in value) + { + if (c < 0x20 || c == 0x7F) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{name}` contains a control character (0x{(int)c:X2})"); + } + } + } + + private static string? ReadString(DbConnectionStringBuilder builder, string key) + { + return builder.TryGetValue(key, out var v) ? (string?)v : null; + } + + private static string? ReadStringOr(DbConnectionStringBuilder builder, string key, string? defaultValue) + { + return ReadString(builder, key) ?? defaultValue; + } + + private static int ReadInt(DbConnectionStringBuilder builder, string key, int defaultValue) + { + if (!builder.TryGetValue(key, out var v)) return defaultValue; + var s = (string)v; + if (!int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + throw new IngressError(ErrorCode.ConfigError, $"`{key}` must be an integer, got `{s}`"); + } + return parsed; + } + + private static CompressionType ParseCompression(DbConnectionStringBuilder builder) + { + if (!builder.TryGetValue("compression", out var v)) return CompressionType.raw; + var s = (string)v; + if (string.Equals(s, "identity", StringComparison.OrdinalIgnoreCase)) return CompressionType.raw; + if (!Enum.TryParse(s, ignoreCase: true, out var parsed)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`compression` must be one of: {string.Join(", ", Enum.GetNames())}, got `{s}`"); + } + return parsed; + } + + private static T ReadEnum(DbConnectionStringBuilder builder, string key, T defaultValue) + where T : struct, Enum + { + if (!builder.TryGetValue(key, out var v)) return defaultValue; + var s = (string)v; + if (!Enum.TryParse(s, ignoreCase: true, out var parsed)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{key}` must be one of: {string.Join(", ", Enum.GetNames())}, got `{s}`"); + } + return parsed; + } + + private static bool ReadBoolOnOff(DbConnectionStringBuilder builder, string key, bool defaultValue) + { + if (!builder.TryGetValue(key, out var v)) return defaultValue; + var s = (string)v; + if (SenderOptions.TryParseInteropBool(s, out var parsed)) return parsed; + throw new IngressError(ErrorCode.ConfigError, + $"`{key}` must be on/off (or true/false), got `{s}`"); + } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpBindSetter.cs b/src/net-questdb-client/Qwp/Query/QwpBindSetter.cs new file mode 100644 index 00000000..d5811b8a --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpBindSetter.cs @@ -0,0 +1,32 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Query; + +/// +/// Callback that populates the bind parameters for a query. Called once per Execute +/// just before the QUERY_REQUEST frame is built. Indices must be strictly ascending starting +/// from 0; gaps and reuses are rejected. +/// +public delegate void QwpBindSetter(QwpBindValues binds); diff --git a/src/net-questdb-client/Qwp/Query/QwpBindValues.cs b/src/net-questdb-client/Qwp/Query/QwpBindValues.cs new file mode 100644 index 00000000..a784349c --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpBindValues.cs @@ -0,0 +1,502 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Numerics; +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Query; + +/// +/// Typed builder for QWP bind parameters. Each bind is encoded as a one-row column under the +/// ingress wire format: [type_code u8][null_flag u8][optional bitmap byte 0x01 if null] +/// followed by type-specific bytes (DECIMAL prefixes scale, GEOHASH prefixes precision varint). +/// +public sealed class QwpBindValues +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, throwOnInvalidBytes: true); + + private const byte NullFlagOff = 0x00; + private const byte NullFlagOn = 0x01; + private const byte NullBitmap = 0x01; + + private const int Decimal64MaxScale = 18; + private const int Decimal128MaxScale = 38; + private const int Decimal256MaxScale = 76; + + private byte[] _buffer = new byte[256]; + private int _length; + private int _expectedNextIndex; + private int _count; + + /// Number of bind parameters set so far. + public int Count => _count; + + /// Returns the encoded bind buffer as a slice of the internal storage. + /// + /// The returned memory is a window over the live internal buffer, not a copy. Any subsequent + /// Set* or call mutates or reallocates that buffer and invalidates + /// the window, so consume (or copy) it before mutating or reusing this instance. + /// + public ReadOnlyMemory AsMemory() => _buffer.AsMemory(0, _length); + + /// Clears all bind state so the instance can be reused for the next query. + public void Reset() + { + _length = 0; + _expectedNextIndex = 0; + _count = 0; + } + + /// Binds a BOOLEAN at . + public QwpBindValues SetBoolean(int index, bool value) + { + Advance(index); + WriteHeader(QwpTypeCode.Boolean, isNull: false); + WriteByte(value ? (byte)1 : (byte)0); + return this; + } + + /// Binds a BYTE (uint8) at . + public QwpBindValues SetByte(int index, byte value) + { + Advance(index); + WriteHeader(QwpTypeCode.Byte, isNull: false); + WriteByte(value); + return this; + } + + /// Binds a SHORT (int16) at . + public QwpBindValues SetShort(int index, short value) + { + Advance(index); + WriteHeader(QwpTypeCode.Short, isNull: false); + WriteI16(value); + return this; + } + + /// Binds a CHAR (UTF-16 code unit) at . + public QwpBindValues SetChar(int index, char value) + { + Advance(index); + WriteHeader(QwpTypeCode.Char, isNull: false); + WriteU16(value); + return this; + } + + /// Binds an INT (int32) at . + public QwpBindValues SetInt(int index, int value) + { + Advance(index); + WriteHeader(QwpTypeCode.Int, isNull: false); + WriteI32(value); + return this; + } + + /// Binds a LONG (int64) at . + public QwpBindValues SetLong(int index, long value) + { + Advance(index); + WriteHeader(QwpTypeCode.Long, isNull: false); + WriteI64(value); + return this; + } + + /// Binds a FLOAT (32-bit IEEE-754) at . + public QwpBindValues SetFloat(int index, float value) + { + Advance(index); + WriteHeader(QwpTypeCode.Float, isNull: false); + WriteI32(BitConverter.SingleToInt32Bits(value)); + return this; + } + + /// Binds a DOUBLE (64-bit IEEE-754) at . + public QwpBindValues SetDouble(int index, double value) + { + Advance(index); + WriteHeader(QwpTypeCode.Double, isNull: false); + WriteI64(BitConverter.DoubleToInt64Bits(value)); + return this; + } + + /// Binds a DATE (milliseconds since Unix epoch) at . + public QwpBindValues SetDate(int index, long millisSinceEpoch) + { + Advance(index); + WriteHeader(QwpTypeCode.Date, isNull: false); + WriteI64(millisSinceEpoch); + return this; + } + + /// Binds a TIMESTAMP (microseconds since Unix epoch) at . + public QwpBindValues SetTimestampMicros(int index, long microsSinceEpoch) + { + Advance(index); + WriteHeader(QwpTypeCode.Timestamp, isNull: false); + WriteI64(microsSinceEpoch); + return this; + } + + /// Binds a TIMESTAMP_NANOS (nanoseconds since Unix epoch) at . + public QwpBindValues SetTimestampNanos(int index, long nanosSinceEpoch) + { + Advance(index); + WriteHeader(QwpTypeCode.TimestampNanos, isNull: false); + WriteI64(nanosSinceEpoch); + return this; + } + + /// Binds a UUID at from explicit lo/hi 64-bit halves (little-endian wire order). + public QwpBindValues SetUuid(int index, long lo, long hi) + { + Advance(index); + WriteHeader(QwpTypeCode.Uuid, isNull: false); + WriteI64(lo); + WriteI64(hi); + return this; + } + + /// Binds a UUID at from a , reordering bytes to QWP wire layout. + public QwpBindValues SetUuid(int index, Guid value) + { + Span ms = stackalloc byte[16]; + if (!value.TryWriteBytes(ms)) + { + throw new InvalidOperationException("Guid.TryWriteBytes failed"); + } + + Span wire = stackalloc byte[16]; + wire[0] = ms[15]; wire[1] = ms[14]; wire[2] = ms[13]; wire[3] = ms[12]; + wire[4] = ms[11]; wire[5] = ms[10]; wire[6] = ms[9]; wire[7] = ms[8]; + wire[8] = ms[6]; wire[9] = ms[7]; wire[10] = ms[4]; wire[11] = ms[5]; + wire[12] = ms[0]; wire[13] = ms[1]; wire[14] = ms[2]; wire[15] = ms[3]; + + var lo = BinaryPrimitives.ReadInt64LittleEndian(wire.Slice(0, 8)); + var hi = BinaryPrimitives.ReadInt64LittleEndian(wire.Slice(8, 8)); + return SetUuid(index, lo, hi); + } + + /// Binds a LONG256 at from four 64-bit words (little-endian, w0 = least significant). + public QwpBindValues SetLong256(int index, long w0, long w1, long w2, long w3) + { + Advance(index); + WriteHeader(QwpTypeCode.Long256, isNull: false); + WriteI64(w0); + WriteI64(w1); + WriteI64(w2); + WriteI64(w3); + return this; + } + + /// Binds a LONG256 at from a non-negative ≤ 256 bits. + public QwpBindValues SetLong256(int index, BigInteger value) + { + if (value.Sign < 0) + { + throw new IngressError(ErrorCode.InvalidApiCall, "LONG256 binds must be non-negative"); + } + + var bytes = value.ToByteArray(isUnsigned: true, isBigEndian: false); + if (bytes.Length > 32) + { + throw new IngressError(ErrorCode.InvalidApiCall, $"LONG256 bind exceeds 256 bits ({bytes.Length * 8})"); + } + + Span padded = stackalloc byte[32]; + bytes.CopyTo(padded); + var w0 = BinaryPrimitives.ReadInt64LittleEndian(padded.Slice(0, 8)); + var w1 = BinaryPrimitives.ReadInt64LittleEndian(padded.Slice(8, 8)); + var w2 = BinaryPrimitives.ReadInt64LittleEndian(padded.Slice(16, 8)); + var w3 = BinaryPrimitives.ReadInt64LittleEndian(padded.Slice(24, 8)); + return SetLong256(index, w0, w1, w2, w3); + } + + /// Binds a GEOHASH at with the given in [1, 60]. + public QwpBindValues SetGeohash(int index, int precisionBits, long value) + { + CheckGeohashPrecision(precisionBits); + Advance(index); + WriteHeader(QwpTypeCode.Geohash, isNull: false); + WriteVarint((ulong)precisionBits); + var byteCount = (precisionBits + 7) >> 3; + var masked = precisionBits >= 64 ? value : value & ((1L << precisionBits) - 1L); + for (var b = 0; b < byteCount; b++) + { + WriteByte((byte)(masked >> (b * 8))); + } + return this; + } + + /// Binds a VARCHAR at ; null input emits a NULL bind. + public QwpBindValues SetVarchar(int index, string? value) + { + if (value is null) return SetNull(index, QwpTypeCode.Varchar); + + Advance(index); + WriteHeader(QwpTypeCode.Varchar, isNull: false); + var byteCount = StrictUtf8.GetByteCount(value); + WriteI32(0); + WriteI32(byteCount); + EnsureCapacity(byteCount); + var written = StrictUtf8.GetBytes(value, _buffer.AsSpan(_length, byteCount)); + if (written != byteCount) + { + throw new InvalidOperationException("UTF-8 byte count mismatch"); + } + _length += byteCount; + return this; + } + + /// Binds a DECIMAL64 at with the given (0–18) and unscaled int64 value. + public QwpBindValues SetDecimal64(int index, int scale, long unscaledValue) + { + CheckScale(scale, Decimal64MaxScale, "DECIMAL64"); + Advance(index); + WriteHeader(QwpTypeCode.Decimal64, isNull: false); + WriteByte((byte)scale); + WriteI64(unscaledValue); + return this; + } + + /// Binds a DECIMAL128 at with (0–38) and a 128-bit two's-complement value (lo/hi halves). + public QwpBindValues SetDecimal128(int index, int scale, long lo, long hi) + { + CheckScale(scale, Decimal128MaxScale, "DECIMAL128"); + Advance(index); + WriteHeader(QwpTypeCode.Decimal128, isNull: false); + WriteByte((byte)scale); + WriteI64(lo); + WriteI64(hi); + return this; + } + + /// Binds a DECIMAL256 at with (0–76) and four 64-bit words in little-endian order. + public QwpBindValues SetDecimal256(int index, int scale, long ll, long lh, long hl, long hh) + { + CheckScale(scale, Decimal256MaxScale, "DECIMAL256"); + Advance(index); + WriteHeader(QwpTypeCode.Decimal256, isNull: false); + WriteByte((byte)scale); + WriteI64(ll); + WriteI64(lh); + WriteI64(hl); + WriteI64(hh); + return this; + } + + /// Binds a NULL at with the given ; for DECIMAL/GEOHASH variants use the type-specific overloads. + public QwpBindValues SetNull(int index, QwpTypeCode typeCode) + { + switch (typeCode) + { + case QwpTypeCode.Decimal64: return SetNullDecimal64(index, 0); + case QwpTypeCode.Decimal128: return SetNullDecimal128(index, 0); + case QwpTypeCode.Decimal256: return SetNullDecimal256(index, 0); + case QwpTypeCode.Geohash: return SetNullGeohash(index, QwpConstants.MinGeohashPrecisionBits); + } + + if (!IsBindableType(typeCode)) + { + throw new IngressError(ErrorCode.InvalidApiCall, $"type {typeCode} (0x{(byte)typeCode:X2}) is not bindable"); + } + + Advance(index); + WriteHeader(typeCode, isNull: true); + return this; + } + + /// Binds a NULL DECIMAL64 at ; is encoded so the server can route the placeholder. + public QwpBindValues SetNullDecimal64(int index, int scale) + { + CheckScale(scale, Decimal64MaxScale, "DECIMAL64"); + Advance(index); + WriteHeader(QwpTypeCode.Decimal64, isNull: true); + WriteByte((byte)scale); + return this; + } + + /// Binds a NULL DECIMAL128 at ; is encoded for routing. + public QwpBindValues SetNullDecimal128(int index, int scale) + { + CheckScale(scale, Decimal128MaxScale, "DECIMAL128"); + Advance(index); + WriteHeader(QwpTypeCode.Decimal128, isNull: true); + WriteByte((byte)scale); + return this; + } + + /// Binds a NULL DECIMAL256 at ; is encoded for routing. + public QwpBindValues SetNullDecimal256(int index, int scale) + { + CheckScale(scale, Decimal256MaxScale, "DECIMAL256"); + Advance(index); + WriteHeader(QwpTypeCode.Decimal256, isNull: true); + WriteByte((byte)scale); + return this; + } + + /// Binds a NULL GEOHASH at with the given . + public QwpBindValues SetNullGeohash(int index, int precisionBits) + { + CheckGeohashPrecision(precisionBits); + Advance(index); + WriteHeader(QwpTypeCode.Geohash, isNull: true); + WriteVarint((ulong)precisionBits); + return this; + } + + private void Advance(int index) + { + if (index != _expectedNextIndex) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"bind index out of order: expected {_expectedNextIndex}, got {index}"); + } + if (_count >= QwpConstants.MaxBindParameters) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"too many bind parameters: exceeds {QwpConstants.MaxBindParameters}"); + } + _expectedNextIndex++; + _count++; + } + + private void WriteHeader(QwpTypeCode typeCode, bool isNull) + { + if (!IsBindableType(typeCode)) + { + throw new IngressError(ErrorCode.InvalidApiCall, $"type {typeCode} (0x{(byte)typeCode:X2}) is not bindable"); + } + + WriteByte((byte)typeCode); + if (isNull) + { + WriteByte(NullFlagOn); + WriteByte(NullBitmap); + } + else + { + WriteByte(NullFlagOff); + } + } + + private static bool IsBindableType(QwpTypeCode t) + { + switch (t) + { + case QwpTypeCode.Boolean: + case QwpTypeCode.Byte: + case QwpTypeCode.Short: + case QwpTypeCode.Char: + case QwpTypeCode.Int: + case QwpTypeCode.Long: + case QwpTypeCode.Float: + case QwpTypeCode.Double: + case QwpTypeCode.Date: + case QwpTypeCode.Timestamp: + case QwpTypeCode.TimestampNanos: + case QwpTypeCode.Uuid: + case QwpTypeCode.Long256: + case QwpTypeCode.Geohash: + case QwpTypeCode.Varchar: + case QwpTypeCode.Decimal64: + case QwpTypeCode.Decimal128: + case QwpTypeCode.Decimal256: + return true; + default: + return false; + } + } + + private static void CheckScale(int scale, int max, string typeName) + { + if (scale < 0 || scale > max) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"{typeName} scale must be in [0, {max}], got {scale}"); + } + } + + private static void CheckGeohashPrecision(int precisionBits) + { + if (precisionBits < QwpConstants.MinGeohashPrecisionBits + || precisionBits > QwpConstants.MaxGeohashPrecisionBits) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"GEOHASH precision must be in [{QwpConstants.MinGeohashPrecisionBits}, {QwpConstants.MaxGeohashPrecisionBits}], got {precisionBits}"); + } + } + + private void WriteByte(byte b) + { + EnsureCapacity(1); + _buffer[_length++] = b; + } + + private void WriteI16(short v) + { + EnsureCapacity(2); + BinaryPrimitives.WriteInt16LittleEndian(_buffer.AsSpan(_length, 2), v); + _length += 2; + } + + private void WriteU16(ushort v) + { + EnsureCapacity(2); + BinaryPrimitives.WriteUInt16LittleEndian(_buffer.AsSpan(_length, 2), v); + _length += 2; + } + + private void WriteI32(int v) + { + EnsureCapacity(4); + BinaryPrimitives.WriteInt32LittleEndian(_buffer.AsSpan(_length, 4), v); + _length += 4; + } + + private void WriteI64(long v) + { + EnsureCapacity(8); + BinaryPrimitives.WriteInt64LittleEndian(_buffer.AsSpan(_length, 8), v); + _length += 8; + } + + private void WriteVarint(ulong value) + { + EnsureCapacity(QwpVarint.MaxBytes); + _length += QwpVarint.Write(_buffer.AsSpan(_length), value); + } + + private void EnsureCapacity(int additional) + { + var required = _length + additional; + if (required <= _buffer.Length) return; + var grown = new byte[Math.Max(required, _buffer.Length * 2)]; + Buffer.BlockCopy(_buffer, 0, grown, 0, _length); + _buffer = grown; + } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpColumnBatch.cs b/src/net-questdb-client/Qwp/Query/QwpColumnBatch.cs new file mode 100644 index 00000000..4414ff6f --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpColumnBatch.cs @@ -0,0 +1,753 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Numerics; +using System.Text; +using QuestDB.Enums; +using QuestDB.Qwp; + +namespace QuestDB.Qwp.Query; + +/// +/// Column-major view over a single decoded RESULT_BATCH. The instance — and every span +/// it returns — is reused across batches: do not store a reference past the +/// onBatch handler invocation, and copy any string / array data you need to keep. +/// +public sealed class QwpColumnBatch +{ + private readonly List _columns = new(); + + /// Server-assigned request id this batch belongs to. + public long RequestId { get; internal set; } + /// Monotonic batch sequence within the request; first batch is 0. + public long BatchSeq { get; internal set; } + /// Number of rows in this batch. + public int RowCount { get; internal set; } + + /// Number of columns in the result set. + public int ColumnCount => _columns.Count; + + /// Returns the wire-level column name at index . + public string GetColumnName(int col) => Col(col).Name; + /// Returns the wire for column . + public QwpTypeCode GetColumnWireType(int col) => Col(col).TypeCode; + /// Returns the decimal scale locked on this column (0 for non-decimal columns). + public byte GetDecimalScale(int col) => Col(col).Scale; + /// Returns the geohash precision in bits for this column (0 for non-geohash columns). + public int GetGeohashPrecisionBits(int col) => Col(col).PrecisionBits; + + /// True if the value at (, ) is NULL. + public bool IsNull(int col, int row) + { + var c = Col(col); + if ((uint)row >= (uint)RowCount) + { + throw new ArgumentOutOfRangeException(nameof(row), + $"row index {row} out of range [0, {RowCount})"); + } + if (c.NonNullIndex is null) return false; + return c.NonNullIndex[row] < 0; + } + + /// Returns the BOOLEAN at (, ); false for NULL. + public bool GetBoolValue(int col, int row) + { + var c = Col(col); + RequireType(c, QwpTypeCode.Boolean); + var i = DenseIndex(c, row); + if (i < 0) return false; + var byteIdx = i >> 3; + var bit = i & 7; + return (c.ValueBytes[byteIdx] & (1 << bit)) != 0; + } + + /// Returns the BYTE (uint8) value; 0 for NULL. + public byte GetByteValue(int col, int row) => GetFixedByte(col, row); + /// Returns the BYTE reinterpreted as int8; 0 for NULL. + public sbyte GetSByteValue(int col, int row) => unchecked((sbyte)GetFixedByte(col, row)); + + /// Returns the SHORT (int16); 0 for NULL. + public short GetShortValue(int col, int row) + { + var c = Col(col); + RequireType(c, QwpTypeCode.Short); + var i = DenseIndex(c, row); + if (i < 0) return 0; + return BinaryPrimitives.ReadInt16LittleEndian(c.ValueBytes.AsSpan(i * 2, 2)); + } + + /// Returns the CHAR (UTF-16 code unit); '\0' for NULL. + public char GetCharValue(int col, int row) + { + var c = Col(col); + RequireType(c, QwpTypeCode.Char); + var i = DenseIndex(c, row); + if (i < 0) return '\0'; + return (char)BinaryPrimitives.ReadUInt16LittleEndian(c.ValueBytes.AsSpan(i * 2, 2)); + } + + /// Returns the INT (int32); 0 for NULL. + public int GetIntValue(int col, int row) + { + var c = Col(col); + RequireType(c, IntFamily); + var i = DenseIndex(c, row); + if (i < 0) return 0; + return BinaryPrimitives.ReadInt32LittleEndian(c.ValueBytes.AsSpan(i * 4, 4)); + } + + /// Returns the LONG (int64); 0 for NULL. + public long GetLongValue(int col, int row) + { + var c = Col(col); + RequireType(c, LongFamily); + return ReadInt64(c, row); + } + + /// Returns the FLOAT (32-bit); 0 for NULL. + public float GetFloatValue(int col, int row) + { + var c = Col(col); + RequireType(c, QwpTypeCode.Float); + var i = DenseIndex(c, row); + if (i < 0) return 0f; + return BitConverter.Int32BitsToSingle( + BinaryPrimitives.ReadInt32LittleEndian(c.ValueBytes.AsSpan(i * 4, 4))); + } + + /// Returns the DOUBLE (64-bit); 0 for NULL. + public double GetDoubleValue(int col, int row) + { + var c = Col(col); + RequireType(c, QwpTypeCode.Double); + var i = DenseIndex(c, row); + if (i < 0) return 0d; + return BitConverter.Int64BitsToDouble( + BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * 8, 8))); + } + + /// Returns the lower 64 bits of a UUID; 0 for NULL. + public long GetUuidLo(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Uuid) + { + throw new InvalidOperationException($"GetUuidLo requires a UUID column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return 0L; + return BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * 16, 8)); + } + + /// Returns the upper 64 bits of a UUID; 0 for NULL. + public long GetUuidHi(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Uuid) + { + throw new InvalidOperationException($"GetUuidHi requires a UUID column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return 0L; + return BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * 16 + 8, 8)); + } + + /// Returns the unscaled int64 of a DECIMAL64 value; 0 for NULL. + public long GetDecimal64UnscaledValue(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Decimal64) + { + throw new InvalidOperationException( + $"GetDecimal64UnscaledValue requires a DECIMAL64 column, got {c.TypeCode}"); + } + return ReadInt64(c, row); + } + + /// Returns the lower 64 bits of a DECIMAL128 unscaled value; 0 for NULL. + public long GetDecimal128Lo(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Decimal128) + { + throw new InvalidOperationException( + $"GetDecimal128Lo requires a DECIMAL128 column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return 0L; + return BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * QwpConstants.Decimal128SizeBytes, 8)); + } + + /// Returns the upper 64 bits of a DECIMAL128 unscaled value; 0 for NULL. + public long GetDecimal128Hi(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Decimal128) + { + throw new InvalidOperationException( + $"GetDecimal128Hi requires a DECIMAL128 column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return 0L; + return BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * QwpConstants.Decimal128SizeBytes + 8, 8)); + } + + /// Returns the four 64-bit limbs (least to most significant) of a DECIMAL256 unscaled value; all zero for NULL. + public void GetDecimal256(int col, int row, out long ll, out long lh, out long hl, out long hh) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Decimal256) + { + throw new InvalidOperationException( + $"GetDecimal256 requires a DECIMAL256 column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) + { + ll = lh = hl = hh = 0L; + return; + } + var baseOff = i * QwpConstants.Decimal256SizeBytes; + var bytes = c.ValueBytes.AsSpan(baseOff, QwpConstants.Decimal256SizeBytes); + ll = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(0, 8)); + lh = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(8, 8)); + hl = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(16, 8)); + hh = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(24, 8)); + } + + /// Returns the four 64-bit limbs of a LONG256 value (least to most significant); all zero for NULL. + public void GetLong256(int col, int row, out long w0, out long w1, out long w2, out long w3) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Long256) + { + throw new InvalidOperationException( + $"GetLong256 requires a LONG256 column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) + { + w0 = w1 = w2 = w3 = 0L; + return; + } + var baseOff = i * QwpConstants.Long256SizeBytes; + var bytes = c.ValueBytes.AsSpan(baseOff, QwpConstants.Long256SizeBytes); + w0 = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(0, 8)); + w1 = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(8, 8)); + w2 = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(16, 8)); + w3 = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(24, 8)); + } + + /// Returns a LONG256 value as a non-negative ; for NULL. + /// + /// is also a legal non-null LONG256 value. Use + /// to disambiguate. + /// + public BigInteger GetLong256(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Long256) + { + throw new InvalidOperationException( + $"GetLong256 requires a LONG256 column, got {c.TypeCode}"); + } + if (IsNull(col, row)) return BigInteger.Zero; + Span bytes = stackalloc byte[QwpConstants.Long256SizeBytes]; + var i = DenseIndex(c, row); + c.ValueBytes.AsSpan(i * QwpConstants.Long256SizeBytes, QwpConstants.Long256SizeBytes).CopyTo(bytes); + return new BigInteger(bytes, isUnsigned: true, isBigEndian: false); + } + + /// Returns the GEOHASH bits packed into a long; -1 for NULL (matches QuestDB's all-bits-set sentinel). + public long GetGeohashValue(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Geohash) + { + throw new InvalidOperationException( + $"GetGeohashValue requires a GEOHASH column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return -1L; + var stride = (c.PrecisionBits + 7) >> 3; + var baseOff = i * stride; + long v = 0; + for (var b = 0; b < stride; b++) + { + v |= ((long)c.ValueBytes[baseOff + b] & 0xFF) << (b * 8); + } + return v; + } + + /// Returns a UUID as ; for NULL. Inverse of QwpBindValues.SetUuid(int, Guid). + /// + /// is also a legal non-null UUID value (the spec defines NULL as both halves + /// equal to long.MinValue, not all zeros). Use to disambiguate. + /// + public Guid GetUuid(int col, int row) + { + if (IsNull(col, row)) return Guid.Empty; + var lo = GetUuidLo(col, row); + var hi = GetUuidHi(col, row); + Span wire = stackalloc byte[16]; + BinaryPrimitives.WriteInt64LittleEndian(wire.Slice(0, 8), lo); + BinaryPrimitives.WriteInt64LittleEndian(wire.Slice(8, 8), hi); + Span ms = stackalloc byte[16]; + ms[0] = wire[12]; ms[1] = wire[13]; ms[2] = wire[14]; ms[3] = wire[15]; + ms[4] = wire[10]; ms[5] = wire[11]; + ms[6] = wire[8]; ms[7] = wire[9]; + ms[8] = wire[7]; ms[9] = wire[6]; + ms[10] = wire[5]; ms[11] = wire[4]; ms[12] = wire[3]; ms[13] = wire[2]; ms[14] = wire[1]; ms[15] = wire[0]; + return new Guid(ms); + } + + /// Returns a TIMESTAMP / TIMESTAMP_NANOS as int64; caller must consult to know the unit. + public long GetTimestampValue(int col, int row) => GetLongValue(col, row); + + /// Returns a DATE as milliseconds since Unix epoch; 0 for NULL. + public long GetDateValue(int col, int row) => GetLongValue(col, row); + + /// IPv4 address as a packed int (4 bytes little-endian on the wire). + public int GetIPv4Value(int col, int row) => GetIntValue(col, row); + + /// Returns the raw bytes of a BINARY value. Span is valid for the duration of the handler. + public ReadOnlySpan GetBinarySpan(int col, int row) + { + var c = Col(col); + if (c.TypeCode is not QwpTypeCode.Binary) + { + throw new InvalidOperationException( + $"GetBinarySpan requires a BINARY column, got {c.TypeCode}"); + } + + var i = DenseIndex(c, row); + if (i < 0) return ReadOnlySpan.Empty; + + var start = c.StringOffsets![i]; + var end = c.StringOffsets[i + 1]; + return c.StringHeap.AsSpan(start, end - start); + } + + /// Returns the UTF-8 bytes of a VARCHAR / SYMBOL value. Span is valid for the duration of the handler. + public ReadOnlySpan GetStringSpan(int col, int row) + { + var c = Col(col); + if (c.TypeCode is not (QwpTypeCode.Varchar or QwpTypeCode.Symbol)) + { + throw new InvalidOperationException( + $"GetStringSpan requires a VARCHAR or SYMBOL column, got {c.TypeCode}"); + } + + var i = DenseIndex(c, row); + if (i < 0) return ReadOnlySpan.Empty; + + if (c.TypeCode == QwpTypeCode.Symbol) + { + return c.SymbolDict!.GetUtf8(c.SymbolIds![i]); + } + + var start = c.StringOffsets![i]; + var end = c.StringOffsets[i + 1]; + return c.StringHeap.AsSpan(start, end - start); + } + + /// + /// Best-effort string rendering of any column. Allocates. Use the typed accessors + /// ( etc.) when you know the column type. + /// + public string? GetString(int col, int row) + { + if (IsNull(col, row)) return null; + var c = Col(col); + return c.TypeCode switch + { + // Best-effort renderer: decode VARCHAR/SYMBOL value bytes with the replacement fallback + // (U+FFFD) rather than QwpConstants.StrictUtf8, so an invalid-UTF-8 server value renders + // a lossy string instead of throwing a raw DecoderFallbackException out of OnBatch. + // StrictUtf8 stays scoped to wire identifiers; callers needing the raw bytes use GetStringSpan. + QwpTypeCode.Varchar or QwpTypeCode.Symbol => Encoding.UTF8.GetString(GetStringSpan(col, row)), + QwpTypeCode.Boolean => GetBoolValue(col, row).ToString(), + QwpTypeCode.Byte => GetByteValue(col, row).ToString(), + QwpTypeCode.Short => GetShortValue(col, row).ToString(), + QwpTypeCode.Char => GetCharValue(col, row).ToString(), + QwpTypeCode.Int => GetIntValue(col, row).ToString(), + QwpTypeCode.IPv4 => FormatIPv4(GetIPv4Value(col, row)), + QwpTypeCode.Long or QwpTypeCode.Date or QwpTypeCode.Timestamp or QwpTypeCode.TimestampNanos + => GetLongValue(col, row).ToString(), + QwpTypeCode.Float => GetFloatValue(col, row).ToString("R"), + QwpTypeCode.Double => GetDoubleValue(col, row).ToString("R"), + QwpTypeCode.Binary => Convert.ToHexString(GetBinarySpan(col, row)), + QwpTypeCode.Uuid => GetUuid(col, row).ToString(), + _ => $"<{c.TypeCode}>", + }; + } + + private static string FormatIPv4(int packed) + { + return $"{(byte)(packed >> 24)}.{(byte)(packed >> 16)}.{(byte)(packed >> 8)}.{(byte)packed}"; + } + + /// Renders a SYMBOL value as a managed string; null for NULL. + public string? GetSymbol(int col, int row) => GetString(col, row); + + /// Returns the dictionary id of a SYMBOL value; -1 for NULL. + public int GetSymbolId(int col, int row) + { + var c = Col(col); + if (c.TypeCode != QwpTypeCode.Symbol) + { + throw new InvalidOperationException( + $"GetSymbolId requires a SYMBOL column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return -1; + return c.SymbolIds![i]; + } + + /// Returns the dimensionality of a *_ARRAY value; 0 for NULL. + public int GetArrayNDims(int col, int row) + { + var (_, _, _, nDims) = ArraySpan(col, row); + return nDims < 0 ? 0 : nDims; + } + + /// + /// Returns the DOUBLE_ARRAY element bytes as a span over the column scratch; valid only for the duration of the handler. + /// Alignment: the underlying byte offset is not guaranteed to be aligned to sizeof(double). + /// Indexing the span (span[i]) works on x86_64 and ARM64 (unaligned loads are single-cycle) but is undefined + /// behaviour on strict-alignment targets. For portable bulk consumption copy via span.ToArray() or read with + /// BinaryPrimitives.ReadDoubleLittleEndian on the underlying byte span. + /// + public ReadOnlySpan GetDoubleArraySpan(int col, int row) + { + var (heap, start, end, nDims) = ArraySpan(col, row, QwpTypeCode.DoubleArray); + if (nDims < 0) return ReadOnlySpan.Empty; + var valueByteCount = ArrayValueByteCount(start, end, nDims, elementBytes: 8); + return System.Runtime.InteropServices.MemoryMarshal + .Cast(heap.AsSpan(start + 1 + nDims * 4, valueByteCount)); + } + + /// + /// Returns the LONG_ARRAY element bytes as a span over the column scratch; valid only for the duration of the handler. + /// Alignment: same caveat as — the span start may not be aligned to + /// sizeof(long); safe on x86_64 / ARM64, UB on strict-alignment targets. Use span.ToArray() or + /// BinaryPrimitives.ReadInt64LittleEndian for portable reads. + /// + public ReadOnlySpan GetLongArraySpan(int col, int row) + { + var (heap, start, end, nDims) = ArraySpan(col, row, QwpTypeCode.LongArray); + if (nDims < 0) return ReadOnlySpan.Empty; + var valueByteCount = ArrayValueByteCount(start, end, nDims, elementBytes: 8); + return System.Runtime.InteropServices.MemoryMarshal + .Cast(heap.AsSpan(start + 1 + nDims * 4, valueByteCount)); + } + + private static int ArrayValueByteCount(int start, int end, int nDims, int elementBytes) + { + var valuesStart = start + 1 + nDims * 4; + var valueByteCount = end - valuesStart; + if (valueByteCount < 0 || valueByteCount % elementBytes != 0) + { + throw new QwpDecodeException( + $"array value bytes {valueByteCount} not a whole multiple of element size {elementBytes}"); + } + return valueByteCount; + } + + /// Allocates and returns the elements of a DOUBLE_ARRAY value; empty array for NULL. Prefer on hot paths. + public double[] GetDoubleArrayElements(int col, int row) + { + var span = GetDoubleArraySpan(col, row); + return span.IsEmpty ? Array.Empty() : span.ToArray(); + } + + /// Allocates and returns the elements of a LONG_ARRAY value; empty array for NULL. Prefer on hot paths. + public long[] GetLongArrayElements(int col, int row) + { + var span = GetLongArraySpan(col, row); + return span.IsEmpty ? Array.Empty() : span.ToArray(); + } + + /// Allocates and returns the per-dimension shape of an array column; empty array for NULL. + public int[] GetArrayShape(int col, int row) + { + var (heap, start, _, nDims) = ArraySpan(col, row); + if (nDims <= 0) return Array.Empty(); + var shape = new int[nDims]; + for (var d = 0; d < nDims; d++) + { + shape[d] = BinaryPrimitives.ReadInt32LittleEndian(heap.AsSpan(start + 1 + d * 4, 4)); + } + return shape; + } + + // expected != null pins the exact element type (DOUBLE_ARRAY vs LONG_ARRAY) for the + // value-returning accessors; shape/ndims accessors pass null since they don't read elements. + private (byte[] Heap, int Start, int End, int NDims) ArraySpan(int col, int row, QwpTypeCode? expected = null) + { + var c = Col(col); + if (expected is { } want) + { + if (c.TypeCode != want) + { + throw new InvalidOperationException( + $"column type mismatch: expected {want}, got {c.TypeCode}"); + } + } + else if (c.TypeCode is not (QwpTypeCode.DoubleArray or QwpTypeCode.LongArray)) + { + throw new InvalidOperationException( + $"array accessors require a DOUBLE_ARRAY or LONG_ARRAY column, got {c.TypeCode}"); + } + var i = DenseIndex(c, row); + if (i < 0) return (Array.Empty(), 0, 0, -1); + var start = c.StringOffsets![i]; + var end = c.StringOffsets[i + 1]; + if (start < 0 || start >= end || end > c.ValueBytes.Length) + { + throw new QwpDecodeException( + $"array row offsets out of range: start={start} end={end} heapLen={c.ValueBytes.Length}"); + } + return (c.ValueBytes, start, end, c.ValueBytes[start]); + } + + /// Returns the number of entries in this column's symbol dictionary; 0 for non-symbol columns. + public int GetSymbolDictSize(int col) => Col(col).SymbolDict?.Size ?? 0; + + /// Looks up a symbol by dictionary id; returns empty span for non-symbol columns. + public ReadOnlySpan GetSymbolForId(int col, int dictId) + { + var dict = Col(col).SymbolDict; + return dict is null ? ReadOnlySpan.Empty : dict.GetUtf8(dictId); + } + + internal void Reset() + { + RequestId = 0; + BatchSeq = 0; + RowCount = 0; + for (var i = 0; i < _columns.Count; i++) _columns[i].Reset(); + } + + internal ColumnView ConfigureColumn(int idx, string name, QwpTypeCode typeCode, byte scale, byte precisionBits) + { + if (idx < _columns.Count) + { + var existing = _columns[idx]; + existing.Reconfigure(name, typeCode, scale, precisionBits); + return existing; + } + + var c = new ColumnView(name, typeCode, scale, precisionBits); + _columns.Add(c); + return c; + } + + internal ColumnView GetColumn(int col) => _columns[col]; + + internal void TrimToColumnCount(int desired) + { + if (_columns.Count <= desired) return; + _columns.RemoveRange(desired, _columns.Count - desired); + } + + private ColumnView Col(int col) + { + if ((uint)col >= (uint)_columns.Count) + { + throw new ArgumentOutOfRangeException(nameof(col), $"column index {col} out of range [0, {_columns.Count})"); + } + return _columns[col]; + } + + private int DenseIndex(ColumnView c, int row) + { + // Bounds check guards against reading residue from a previous batch — scratches survive Reset. + if ((uint)row >= (uint)RowCount) + { + throw new ArgumentOutOfRangeException(nameof(row), + $"row index {row} out of range [0, {RowCount})"); + } + if (c.NonNullIndex is null) return row; + return c.NonNullIndex[row]; + } + + private byte GetFixedByte(int col, int row) + { + var c = Col(col); + RequireType(c, QwpTypeCode.Byte); + var i = DenseIndex(c, row); + if (i < 0) return 0; + return c.ValueBytes[i]; + } + + private long ReadInt64(ColumnView c, int row) + { + var i = DenseIndex(c, row); + if (i < 0) return 0; + return BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * 8, 8)); + } + + // GetLongValue / GetIntValue are the int64 / int32 readers for a family of wire types that + // share a physical width (a designated TIMESTAMP is read via GetLongValue, an IPv4 via + // GetIntValue), so the guard admits the whole family rather than a single code. + private static readonly QwpTypeCode[] LongFamily = + { QwpTypeCode.Long, QwpTypeCode.Date, QwpTypeCode.Timestamp, QwpTypeCode.TimestampNanos }; + + private static readonly QwpTypeCode[] IntFamily = + { QwpTypeCode.Int, QwpTypeCode.IPv4 }; + + // Guards a fixed-width numeric accessor against a wrong-typed column. The scratch buffers are + // pooled across batches (see ColumnView.Reset), so without this a reader would return residue + // from a prior batch as a plausible-looking wrong value instead of throwing. + private static void RequireType(ColumnView c, QwpTypeCode expected) + { + if (c.TypeCode != expected) + { + throw new InvalidOperationException( + $"column type mismatch: expected {expected}, got {c.TypeCode}"); + } + } + + private static void RequireType(ColumnView c, ReadOnlySpan expected) + { + foreach (var e in expected) + { + if (c.TypeCode == e) return; + } + throw new InvalidOperationException( + $"column type mismatch: expected one of [{string.Join(", ", expected.ToArray())}], got {c.TypeCode}"); + } +} + +internal sealed class ColumnView +{ + public ColumnView(string name, QwpTypeCode typeCode, byte scale, byte precisionBits) + { + Name = name; + TypeCode = typeCode; + Scale = scale; + PrecisionBits = precisionBits; + } + + public string Name { get; private set; } + public QwpTypeCode TypeCode { get; private set; } + public byte Scale { get; internal set; } + public byte PrecisionBits { get; internal set; } + + public int[]? NonNullIndex { get; set; } + public int[]? StringOffsets { get; set; } + public int[]? SymbolIds { get; set; } + + public byte[] ValueBytes { get; set; } = Array.Empty(); + public byte[] StringHeap { get; set; } = Array.Empty(); + + internal int[] NonNullIndexBuf = Array.Empty(); + internal int[] StringOffsetsBuf = Array.Empty(); + internal int[] SymbolIdsBuf = Array.Empty(); + + public QwpEgressSymbolDict? SymbolDict { get; set; } + + public void Reconfigure(string name, QwpTypeCode typeCode, byte scale, byte precisionBits) + { + Name = name; + TypeCode = typeCode; + Scale = scale; + PrecisionBits = precisionBits; + Reset(); + } + + // Per-batch sentinels reset; scratch buffers survive so the decoder pools across batches. + public void Reset() + { + NonNullIndex = null; + StringOffsets = null; + SymbolIds = null; + SymbolDict = null; + } +} + +internal sealed class QwpEgressSymbolDict +{ + private readonly List _offsets = new() { 0 }; + private byte[] _heap = Array.Empty(); + private int _heapLen; + + public int Size => _offsets.Count - 1; + + public void Reset() + { + _offsets.Clear(); + _offsets.Add(0); + _heapLen = 0; + } + + public void TruncateTo(int size) + { + if (size < 0 || size > Size) + { + throw new ArgumentOutOfRangeException(nameof(size)); + } + if (size == Size) return; + _offsets.RemoveRange(size + 1, _offsets.Count - (size + 1)); + _heapLen = _offsets[^1]; + } + + public void AppendEntry(ReadOnlySpan utf8) + { + if (Size >= QwpConstants.MaxConnSymbolDictEntries) + { + throw new QwpDecodeException( + $"symbol dict entry count exceeds {QwpConstants.MaxConnSymbolDictEntries}"); + } + var needed = _heapLen + utf8.Length; + if (needed > QwpConstants.MaxConnSymbolDictHeapBytes) + { + throw new QwpDecodeException( + $"symbol dict heap exceeds {QwpConstants.MaxConnSymbolDictHeapBytes} bytes"); + } + if (needed > _heap.Length) + { + var grown = new byte[Math.Max(needed, Math.Max(64, _heap.Length * 2))]; + Buffer.BlockCopy(_heap, 0, grown, 0, _heapLen); + _heap = grown; + } + utf8.CopyTo(_heap.AsSpan(_heapLen)); + _heapLen += utf8.Length; + _offsets.Add(_heapLen); + } + + public ReadOnlySpan GetUtf8(int id) + { + if ((uint)id >= (uint)Size) + { + throw new ArgumentOutOfRangeException(nameof(id), $"symbol id {id} out of range [0, {Size})"); + } + + var start = _offsets[id]; + var end = _offsets[id + 1]; + return _heap.AsSpan(start, end - start); + } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpColumnBatchHandler.cs b/src/net-questdb-client/Qwp/Query/QwpColumnBatchHandler.cs new file mode 100644 index 00000000..1bcc51a6 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpColumnBatchHandler.cs @@ -0,0 +1,52 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; + +namespace QuestDB.Qwp.Query; + +/// +/// Receives result events from QueryClient.Execute. The batch passed to +/// is valid only for the duration of the call; spans returned from +/// its accessors must not escape the handler invocation. Each query produces zero or more +/// calls followed by exactly one terminator: , +/// , or . +/// +public abstract class QwpColumnBatchHandler +{ + /// Invoked once per RESULT_BATCH. Spans from must not escape the call. + public virtual void OnBatch(QwpColumnBatch batch) { } + + /// Terminator for queries that returned rows; is the cumulative row count. + public virtual void OnEnd(long totalRows) { } + + /// Terminator for queries that failed; is the QWP status code. + public virtual void OnError(QwpStatusCode status, string message) { } + + /// Terminator for non-row-returning ops (DDL/DML); identifies the operation, the row count when applicable. + public virtual void OnExecDone(QwpOpType opType, long rowsAffected) { } + + /// Fired when the connection failed over to a new node; the in-flight query is restarted from scratch. + public virtual void OnFailoverReset(QwpServerInfo? newNode) { } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpDecodeException.cs b/src/net-questdb-client/Qwp/Query/QwpDecodeException.cs new file mode 100644 index 00000000..603335c4 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpDecodeException.cs @@ -0,0 +1,46 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Query; + +/// +/// Raised by the egress decoder ( and +/// ) when a RESULT_BATCH frame is malformed or corrupt: +/// truncated payloads, lengths or counts out of range, unsupported column type codes, +/// overlong varints, or a bad Gorilla timestamp column. The query loop in +/// QwpQueryWebSocketClient catches it and re-wraps it as an +/// IngressError with ErrorCode.ProtocolViolation, so callers of the query +/// API observe a protocol violation rather than this internal decode failure. +/// +public sealed class QwpDecodeException : Exception +{ + /// Constructs the exception with a description of the malformed frame. + public QwpDecodeException(string message) : base(message) { } + + /// + /// Constructs the exception with a description and the underlying runtime failure + /// (e.g. a varint or Gorilla decode error), so the raw exception isn't surfaced directly. + /// + public QwpDecodeException(string message, Exception inner) : base(message, inner) { } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpEgressConnState.cs b/src/net-questdb-client/Qwp/Query/QwpEgressConnState.cs new file mode 100644 index 00000000..a90f84ac --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpEgressConnState.cs @@ -0,0 +1,61 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; + +namespace QuestDB.Qwp.Query; + +/// +/// Connection-scoped egress state. Holds the symbol dictionary that survives across queries +/// within a single connection. The column schema rides only the first RESULT_BATCH of a query +/// (batch_seq == 0) and is owned by , not here. +/// +internal sealed class QwpEgressConnState +{ + public QwpEgressSymbolDict SymbolDict { get; } = new(); + + public void ResetSymbolDict() => SymbolDict.Reset(); +} + +internal readonly struct EgressColumnDef +{ + public EgressColumnDef(string name, QwpTypeCode typeCode) + { + Name = name; + TypeCode = typeCode; + } + + public string Name { get; } + public QwpTypeCode TypeCode { get; } +} + +internal sealed class EgressSchema +{ + public EgressSchema(EgressColumnDef[] columns) + { + Columns = columns; + } + + public EgressColumnDef[] Columns { get; } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpQueryWebSocketClient.cs b/src/net-questdb-client/Qwp/Query/QwpQueryWebSocketClient.cs new file mode 100644 index 00000000..c7295894 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpQueryWebSocketClient.cs @@ -0,0 +1,1237 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Net.WebSockets; +using System.Text; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; +using QuestDB.Senders; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Query; + +internal sealed class QwpQueryWebSocketClient : IQwpQueryClient +{ + private static readonly UTF8Encoding StrictUtf8 = QwpConstants.StrictUtf8; + private const int InitialReceiveBufferBytes = 64 * 1024; + private static readonly TimeSpan ServerInfoReadTimeout = TimeSpan.FromSeconds(5); + + private readonly QueryOptions _options; + private readonly QwpEgressConnState _connState = new(); + private readonly QwpResultBatchDecoder _decoder; + private readonly QwpColumnBatch _batch = new(); + private readonly SemaphoreSlim _executeLock = new(1, 1); + // ClientWebSocket.SendAsync is not safe under concurrent senders; Cancel() is foreign-thread. + private readonly SemaphoreSlim _sendLock = new(1, 1); + + private QwpWebSocketTransport? _transport; + private readonly QwpHostHealthTracker _hostTracker; + private readonly QwpTlsAuth.CertificateValidator? _certValidator; + private int _activeAddressIndex = -1; + private byte[] _receiveBuffer = new byte[InitialReceiveBufferBytes]; + private byte[] _decompressBuffer = Array.Empty(); + private byte[] _queryRequestBuf = Array.Empty(); + private readonly byte[] _cancelFrameBuf = new byte[1 + 8]; + private readonly byte[] _creditFrameBuf = new byte[1 + 8 + QwpVarint.MaxBytes]; + private ZstdSharp.Decompressor? _decompressor; + private long _nextRequestId; + private long _currentRequestId = -1; + private long _pendingCreditBytes; + private long _expectedBatchSeq; + private int _disposed; + private int _terminal; + private long _cancelTargetRid = -1; + // Either flag suppresses MarkTerminal in finally: cleanly = wire-side terminator or user-cancelled; + // drainOk = user callback threw but the connection is still recoverable. + private bool _executeFinishedCleanly; + private bool _drainOkAfterHandlerThrow; + private int _lastCloseTimedOut; + + private QwpQueryWebSocketClient(QueryOptions options) + { + _options = options; + _decoder = new QwpResultBatchDecoder(_connState); + _hostTracker = new QwpHostHealthTracker( + options.addresses, + clientZone: options.zone, + targetIsPrimary: options.target == TargetType.primary); + // Built once and reused across every (re)connect; owns the loaded custom-root cert so it + // is freed deterministically on Dispose rather than leaking one per BuildTransport call. + _certValidator = QwpTlsAuth.BuildCertificateValidator( + options.tls_verify, options.tls_roots, options.tls_roots_password); + } + + internal static async Task CreateAsync(QueryOptions options, CancellationToken ct) + { + var client = new QwpQueryWebSocketClient(options); + try + { + await client.ConnectInitialAsync(ct).ConfigureAwait(false); + } + catch (Exception) + { + await client.DisposeAsync().ConfigureAwait(false); + throw; + } + return client; + } + + public QwpServerInfo? ServerInfo { get; private set; } + + public int NegotiatedVersion => _transport?.NegotiatedVersion ?? 0; + + public string? NegotiatedCompression => _transport?.NegotiatedContentEncoding; + + public bool WasLastCloseTimedOut => Volatile.Read(ref _lastCloseTimedOut) != 0; + + public void Execute(string sql, QwpColumnBatchHandler handler) => + Task.Run(() => ExecuteCoreAsync(sql, binds: null, handler, CancellationToken.None)) + .GetAwaiter().GetResult(); + + public void Execute(string sql, QwpBindSetter binds, QwpColumnBatchHandler handler) => + Task.Run(() => ExecuteCoreAsync(sql, binds, handler, CancellationToken.None)) + .GetAwaiter().GetResult(); + + public Task ExecuteAsync(string sql, QwpColumnBatchHandler handler, CancellationToken ct = default) => + ExecuteCoreAsync(sql, binds: null, handler, ct); + + public Task ExecuteAsync(string sql, QwpBindSetter binds, QwpColumnBatchHandler handler, + CancellationToken cancellationToken = default) => + ExecuteCoreAsync(sql, binds, handler, cancellationToken); + + private async Task ExecuteCoreAsync( + string sql, QwpBindSetter? binds, QwpColumnBatchHandler handler, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(sql); + ArgumentNullException.ThrowIfNull(handler); + ThrowIfDisposed(); + ThrowIfTerminal(); + + var sqlByteCount = StrictUtf8.GetByteCount(sql); + if (sqlByteCount > QwpConstants.MaxSqlLengthBytes) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"SQL exceeds {QwpConstants.MaxSqlLengthBytes} byte limit (got {sqlByteCount})"); + } + + var bindBlob = ReadOnlyMemory.Empty; + var bindCount = 0; + if (binds is not null) + { + var bindValues = new QwpBindValues(); + binds(bindValues); + bindBlob = bindValues.AsMemory(); + bindCount = bindValues.Count; + } + + if (!await _executeLock.WaitAsync(0, ct).ConfigureAwait(false)) + { + throw new IngressError(ErrorCode.InvalidApiCall, + "Execute is in flight; one query at a time per client"); + } + + _executeFinishedCleanly = false; + _drainOkAfterHandlerThrow = false; + _hostTracker.BeginRound(forgetClassifications: false); + var requestId = Interlocked.Increment(ref _nextRequestId); + Interlocked.Exchange(ref _currentRequestId, requestId); + try + { + var attempt = 0; + var backoffPolicy = new QwpReconnectPolicy( + _options.failover_backoff_initial_ms, + _options.failover_backoff_max_ms, + _options.failover_max_duration_ms > TimeSpan.Zero + ? _options.failover_max_duration_ms + : _options.failover_backoff_max_ms, + QwpReconnectPolicy.FullJitter); + var failoverDeadline = _options.failover_max_duration_ms > TimeSpan.Zero + ? Environment.TickCount64 + (long)_options.failover_max_duration_ms.TotalMilliseconds + : long.MaxValue; + while (true) + { + _pendingCreditBytes = 0; + _expectedBatchSeq = 0; + // The schema rides only batch_seq == 0; invalidate any schema left over from the + // prior query so a continuation batch can't bind rows to a stale schema. + _decoder.ResetQuerySchema(); + try + { + await SendQueryRequestAsync(requestId, sql, sqlByteCount, _options.initial_credit, bindBlob, bindCount, ct) + .ConfigureAwait(false); + await DriveQueryLoopAsync(handler, ct).ConfigureAwait(false); + _executeFinishedCleanly = true; + return; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // ClientWebSocket.ReceiveAsync aborts the socket before throwing on token-cancel, + // so SendCancel is not deliverable. The connection is unrecoverable; finally + // MarkTerminal()s and the caller must build a fresh client. Use Cancel() for + // graceful cancellation that gives the server a CANCEL frame. + throw; + } + catch (Exception ex) when ( + _options.failover + && attempt + 1 < _options.failover_max_attempts + && Environment.TickCount64 < failoverDeadline + && IsTransportError(ex) + && !ct.IsCancellationRequested + && Interlocked.Read(ref _cancelTargetRid) != requestId + && Volatile.Read(ref _disposed) == 0) + { + if (_activeAddressIndex >= 0) _hostTracker.RecordMidStreamFailure(_activeAddressIndex); + var sleep = backoffPolicy.ComputeBackoff(attempt); + var remainingMs = failoverDeadline - Environment.TickCount64; + if (remainingMs <= 0) throw; + if (sleep.TotalMilliseconds > remainingMs) + sleep = TimeSpan.FromMilliseconds(remainingMs); + await Task.Delay(sleep, ct).ConfigureAwait(false); + if (Interlocked.Read(ref _cancelTargetRid) == requestId) + { + throw new OperationCanceledException("query cancelled during failover"); + } + attempt++; + await ReconnectAsync(attempt, ct).ConfigureAwait(false); + if (Interlocked.Read(ref _cancelTargetRid) == requestId) + { + throw new OperationCanceledException("query cancelled during failover"); + } + // An OnFailoverReset throw is terminal: the query was abandoned by the reconnect, + // so leave _drainOkAfterHandlerThrow false and let the finally MarkTerminal(). + handler.OnFailoverReset(ServerInfo); + } + } + } + finally + { + if (!_executeFinishedCleanly && !_drainOkAfterHandlerThrow) MarkTerminal(); + Interlocked.Exchange(ref _currentRequestId, -1); + // The decompressor is reused across queries, so reclaim it only on the disposal path: + // when Dispose() races an in-flight Execute it can't take _executeLock and skips + // DisposeDecompressor, so this query — its last user — frees the native context as it + // exits. Done under _executeLock (like the Dispose paths) so disposal stays serialised + // with the decode loop's use of the decompressor. Idempotent via Interlocked.Exchange. + if (Volatile.Read(ref _disposed) != 0) DisposeDecompressor(); + _executeLock.Release(); + } + } + + private static bool IsTransportError(Exception ex) + { + // ProtocolVersionError stays retryable (another endpoint may run a compatible version); + // ProtocolViolation is structural frame corruption — terminal, never retried. + return ex switch + { + IngressError ie => ie.code is ErrorCode.SocketError or ErrorCode.ProtocolVersionError, + System.Net.WebSockets.WebSocketException => true, + IOException => true, + ObjectDisposedException => true, + _ => false, + }; + } + + private async Task ReconnectAsync(int attempt, CancellationToken ct) + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(QwpQueryWebSocketClient)); + } + Interlocked.Exchange(ref _transport, null)?.Dispose(); + _hostTracker.BeginRound(forgetClassifications: false); + + var (info, lastError, anyRoleMismatch) = await WalkTrackerAsync(ct).ConfigureAwait(false); + if (_transport is not null) + { + ServerInfo = info; + _connState.ResetSymbolDict(); + // No schema reset needed here: the per-query schema lives on the decoder and is + // invalidated by the next Execute() call's ResetQuerySchema(). + return; + } + + var addrCount = _hostTracker.Count; + if (!anyRoleMismatch && lastError is not null) + { + throw new IngressError(ErrorCode.SocketError, + $"failover exhausted after {attempt} attempt(s) across {addrCount} endpoint(s): {lastError.Message}", + lastError); + } + + throw new QwpRoleMismatchException(_options.target, info, + lastError is null + ? $"failover exhausted after {attempt} attempt(s) across {addrCount} endpoint(s): no endpoint matched target={_options.target}" + : $"failover exhausted after {attempt} attempt(s) across {addrCount} endpoint(s): {lastError.Message}"); + } + + private async Task<(QwpServerInfo? LastInfo, Exception? LastError, bool AnyRoleMismatch)> + WalkTrackerAsync(CancellationToken ct) + { + QwpServerInfo? lastInfo = null; + Exception? lastError = null; + var anyRoleMismatch = false; + var retriedAfterReset = false; + while (true) + { + var idx = _hostTracker.PickNext(); + if (idx < 0) + { + if (!retriedAfterReset) + { + _hostTracker.BeginRound(forgetClassifications: true); + retriedAfterReset = true; + continue; + } + return (lastInfo, lastError, anyRoleMismatch); + } + + var addr = _hostTracker.GetHost(idx); + QwpWebSocketTransport? candidate = null; + try + { + candidate = BuildTransport(addr); + + QwpServerInfo? info = null; + using (var upgradeCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + upgradeCts.CancelAfter(_options.auth_timeout_ms); + try + { + await candidate.ConnectAsync(upgradeCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (upgradeCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + throw new IngressError(ErrorCode.SocketError, + $"WebSocket upgrade for {addr} exceeded auth_timeout={_options.auth_timeout_ms.TotalMilliseconds}ms"); + } + } + + // SERVER_INFO is unconditionally delivered post-upgrade — no v2 gate any more. + using (var serverInfoCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + serverInfoCts.CancelAfter(ServerInfoReadTimeout); + try + { + info = await ReadServerInfoFrameAsync(candidate, serverInfoCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (serverInfoCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + throw new IngressError(ErrorCode.SocketError, + $"SERVER_INFO read for {addr} exceeded {ServerInfoReadTimeout.TotalMilliseconds}ms"); + } + } + + // info is non-null here: ReadServerInfoFrameAsync returns non-null or throws. + if ((info!.Capabilities & QwpConstants.CapZone) != 0) + { + _hostTracker.RecordZone(idx, info.ZoneId); + } + + lastInfo = info; + if (RoleMatchesTarget(info.Role, _options.target)) + { + if (Volatile.Read(ref _disposed) != 0) + { + candidate.Dispose(); + throw new ObjectDisposedException(nameof(QwpQueryWebSocketClient)); + } + Interlocked.Exchange(ref _transport, candidate)?.Dispose(); + _activeAddressIndex = idx; + _hostTracker.RecordSuccess(idx); + return (lastInfo, lastError, anyRoleMismatch); + } + + anyRoleMismatch = true; + _hostTracker.RecordRoleReject(idx, transient: info.Role == QwpRole.PrimaryCatchup); + lastError = new IngressError(ErrorCode.ConfigError, + $"endpoint {addr} role {info.RoleName} does not match target={_options.target}"); + candidate.Dispose(); + candidate = null; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + candidate?.Dispose(); + throw; + } + catch (IngressError ex) when (ex.code is ErrorCode.AuthError) + { + candidate?.Dispose(); + throw; + } + catch (QwpIngressRoleRejectedException ex) + { + anyRoleMismatch = true; + lastInfo = SynthesiseRoleRejectInfo(ex); + lastError = ex; + _hostTracker.RecordZone(idx, ex.Zone); + _hostTracker.RecordRoleReject(idx, ex.IsTransient); + candidate?.Dispose(); + } + catch (Exception ex) + { + lastError = ex; + _hostTracker.RecordTransportError(idx); + candidate?.Dispose(); + } + } + } + + public void Cancel() + { + var rid = Interlocked.Read(ref _currentRequestId); + if (rid < 0) return; + // Record the exact rid being cancelled. If this thread is pre-empted between the read above + // and here while query `rid` finishes and the next query starts, the stale rid no longer + // matches the running requestId, so the failover-path checks ignore it. + Interlocked.Exchange(ref _cancelTargetRid, rid); + if (Volatile.Read(ref _disposed) != 0) return; + + try + { + // Bounded so a wedged I/O loop can't deadlock a foreign-thread Cancel. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + SendCancelAsync(rid, cts.Token).GetAwaiter().GetResult(); + } + catch + { + // Best-effort cancel; the connection is being torn down regardless. + } + } + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + CloseAndDisposeTransport(Interlocked.Exchange(ref _transport, null)); + var locked = _executeLock.Wait(TimeSpan.FromSeconds(5)); + Volatile.Write(ref _lastCloseTimedOut, locked ? 0 : 1); + CloseAndDisposeTransport(Interlocked.Exchange(ref _transport, null)); + if (locked) + { + DisposeDecompressor(); + _executeLock.Release(); + // !locked path leaks _executeLock/_sendLock so a foreign in-flight Execute thread + // doesn't hit ObjectDisposedException on its finally Release. + try { _executeLock.Dispose(); } catch { } + try { _sendLock.Dispose(); } catch { } + } + + try { _certValidator?.Dispose(); } catch { } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + await CloseAndDisposeTransportAsync(Interlocked.Exchange(ref _transport, null)) + .ConfigureAwait(false); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var locked = false; + try + { + await _executeLock.WaitAsync(cts.Token).ConfigureAwait(false); + locked = true; + } + catch (OperationCanceledException) { } + Volatile.Write(ref _lastCloseTimedOut, locked ? 0 : 1); + await CloseAndDisposeTransportAsync(Interlocked.Exchange(ref _transport, null)) + .ConfigureAwait(false); + if (locked) + { + DisposeDecompressor(); + _executeLock.Release(); + try { _executeLock.Dispose(); } catch { } + try { _sendLock.Dispose(); } catch { } + } + + try { _certValidator?.Dispose(); } catch { } + } + + private static void CloseAndDisposeTransport(QwpWebSocketTransport? transport) + { + if (transport is null) return; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + transport.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token) + .GetAwaiter().GetResult(); + } + catch { } + transport.Dispose(); + } + + private static async Task CloseAndDisposeTransportAsync(QwpWebSocketTransport? transport) + { + if (transport is null) return; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await transport.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token) + .ConfigureAwait(false); + } + catch { } + transport.Dispose(); + } + + private void DisposeDecompressor() + { + var d = Interlocked.Exchange(ref _decompressor, null); + try { d?.Dispose(); } catch { } + } + + private static Uri BuildUri(QueryOptions options, string addr) + { + // addr is host[:port] only; '/' '@' or whitespace would smuggle a path or userinfo + // straight into the composed URI string. + if (string.IsNullOrEmpty(addr) + || addr.IndexOfAny(new[] { '/', '@' }) >= 0 + || addr.AsSpan().IndexOfAny(' ', '\t') >= 0 + || addr.IndexOf('\n') >= 0 || addr.IndexOf('\r') >= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed addr '{addr}': expected host[:port] with no path, userinfo, or whitespace"); + } + var scheme = options.protocol == ProtocolType.wss ? "wss" : "ws"; + var path = options.path; + if (!path.StartsWith('/')) path = "/" + path; + return new Uri($"{scheme}://{addr}{path}"); + } + + private QwpWebSocketTransport BuildTransport(string addr) + { + var extras = new Dictionary(StringComparer.Ordinal); + var accept = BuildAcceptEncoding(_options); + if (accept is not null) extras[QwpConstants.HeaderAcceptEncoding] = accept; + if (_options.max_batch_rows > 0) + { + extras[QwpConstants.HeaderMaxBatchRows] = + _options.max_batch_rows.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + var transportOpts = new QwpWebSocketTransportOptions + { + Uri = BuildUri(_options, addr), + ClientMaxVersion = QwpConstants.SupportedVersion, + ClientId = _options.client_id, + AuthorizationHeader = QwpTlsAuth.BuildAuthHeader( + _options.username, _options.password, _options.token), + RemoteCertificateValidationCallback = _certValidator?.Callback, + ExtraRequestHeaders = extras.Count > 0 ? extras : null, + }; + return new QwpWebSocketTransport(transportOpts); + } + + private static string? BuildAcceptEncoding(QueryOptions options) + { + return options.compression switch + { + CompressionType.raw => null, + CompressionType.zstd => $"zstd;level={options.compression_level},raw", + CompressionType.auto => $"zstd;level={options.compression_level},raw", + _ => throw new InvalidOperationException( + $"unknown CompressionType {options.compression}"), + }; + } + + private async Task ConnectInitialAsync(CancellationToken ct) + { + var (info, lastError, anyRoleMismatch) = await WalkTrackerAsync(ct).ConfigureAwait(false); + if (_transport is not null) + { + ServerInfo = info; + return; + } + + if (!anyRoleMismatch && lastError is not null) + { + throw new IngressError(ErrorCode.SocketError, + $"connect failed against every endpoint: {lastError.Message}", + lastError); + } + + throw new QwpRoleMismatchException(_options.target, info, + lastError is null + ? $"no endpoint matched target={_options.target} (last observed role: {info?.RoleName ?? ""})" + : $"connect failed against every endpoint: {lastError.Message}"); + } + + internal static QwpServerInfo SynthesiseRoleRejectInfo(QwpIngressRoleRejectedException ex) => new() + { + // Role-header strings are case-insensitive per the failover spec; align here with the + // RoleRejected.IsTransient check rather than the case-sensitive switch. + Role = MapRoleName(ex.Role), + ZoneId = ex.Zone, + }; + + internal static QwpRole MapRoleName(string? role) + { + if (string.IsNullOrEmpty(role)) return QwpRole.Undefined; + if (string.Equals(role, QwpConstants.RoleStandaloneName, StringComparison.OrdinalIgnoreCase)) + return QwpRole.Standalone; + if (string.Equals(role, QwpConstants.RolePrimaryName, StringComparison.OrdinalIgnoreCase)) + return QwpRole.Primary; + if (string.Equals(role, QwpConstants.RoleReplicaName, StringComparison.OrdinalIgnoreCase)) + return QwpRole.Replica; + if (string.Equals(role, QwpConstants.RolePrimaryCatchupName, StringComparison.OrdinalIgnoreCase)) + return QwpRole.PrimaryCatchup; + return QwpRole.Undefined; + } + + internal static bool RoleMatchesTarget(QwpRole role, TargetType target) + { + // target=any only accepts the spec-defined roles; unknown values (including the Undefined + // sentinel) must topology-reject so a future or buggy server is rejected loudly rather than + // masked as "matches anything". + if (role is not (QwpRole.Standalone or QwpRole.Primary + or QwpRole.Replica or QwpRole.PrimaryCatchup)) + { + return false; + } + return target switch + { + TargetType.any => true, + TargetType.primary => role is QwpRole.Standalone or QwpRole.Primary + or QwpRole.PrimaryCatchup, + TargetType.replica => role == QwpRole.Replica, + _ => false, + }; + } + + private async Task ReadServerInfoFrameAsync(QwpWebSocketTransport transport, CancellationToken ct) + { + var recv = await transport + .ReceiveFrameAsync(_receiveBuffer, QwpConstants.MaxResultBatchWireBytes, ct) + .ConfigureAwait(false); + _receiveBuffer = recv.Buffer; + var (kind, payload, _) = SliceFrame(recv.Buffer, recv.Read); + if (kind != QwpEgressMsgKind.ServerInfo) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"server must send SERVER_INFO as the first frame, got 0x{(byte)kind:X2}"); + } + return DecodeServerInfo(payload); + } + + // Bounds a server that floods stale-request / CACHE_RESET frames so a sync Execute + // (CancellationToken.None) cannot hang forever; reset on every active-request frame. + private const int MaxConsecutiveNonProgressFrames = 1024; + + private async Task DriveQueryLoopAsync(QwpColumnBatchHandler handler, CancellationToken ct) + { + var activeRid = Volatile.Read(ref _currentRequestId); + var consecutiveNonProgress = 0; + while (true) + { + var (kind, payload, headerFlags) = await ReadFrameAsync(ct).ConfigureAwait(false); + switch (kind) + { + case QwpEgressMsgKind.ResultBatch: + var batchBytes = payload.Length; + var decoded = MaybeDecompressResultBatch(payload, headerFlags); + try + { + _decoder.Decode(decoded.Span, headerFlags, _batch); + } + catch (QwpDecodeException ex) + { + throw new IngressError(ErrorCode.ProtocolViolation, ex.Message, ex); + } + var batchRid = _batch.RequestId; + if (batchRid != activeRid) + { + // Return credit for a stale request_id directly so the server's + // per-request window for that id is not leaked. + if (_options.initial_credit > 0) + { + await SendCreditAsync(batchRid, batchBytes + QwpConstants.HeaderSize, ct) + .ConfigureAwait(false); + } + ThrowIfNonProgressExceeded(++consecutiveNonProgress, activeRid); + continue; + } + if (_batch.BatchSeq != _expectedBatchSeq) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"out-of-order RESULT_BATCH for request_id={batchRid}: expected batch_seq={_expectedBatchSeq}, got {_batch.BatchSeq}"); + } + consecutiveNonProgress = 0; + _expectedBatchSeq++; + try + { + handler.OnBatch(_batch); + } + catch + { + _drainOkAfterHandlerThrow = await CancelAndDrainAsync(batchRid) + .ConfigureAwait(false); + throw; + } + if (_options.initial_credit > 0) + { + _pendingCreditBytes += batchBytes + QwpConstants.HeaderSize; + var threshold = Math.Max(1L, _options.initial_credit / 2); + if (_pendingCreditBytes >= threshold) + { + var toReturn = _pendingCreditBytes; + _pendingCreditBytes = 0; + await SendCreditAsync(batchRid, toReturn, ct).ConfigureAwait(false); + } + } + break; + + case QwpEgressMsgKind.ResultEnd: + var (endRid, endTotal) = DecodeResultEnd(payload); + if (endRid != activeRid) + { + ThrowIfNonProgressExceeded(++consecutiveNonProgress, activeRid); + continue; + } + _executeFinishedCleanly = true; + handler.OnEnd(endTotal); + return; + + case QwpEgressMsgKind.ExecDone: + var (execRid, opType, rowsAffected) = DecodeExecDone(payload); + if (execRid != activeRid) + { + ThrowIfNonProgressExceeded(++consecutiveNonProgress, activeRid); + continue; + } + _executeFinishedCleanly = true; + handler.OnExecDone((QwpOpType)opType, rowsAffected); + return; + + case QwpEgressMsgKind.QueryError: + var (errRid, status, message) = DecodeQueryError(payload); + if (errRid != activeRid && errRid != QwpConstants.RequestIdWildcard) + { + ThrowIfNonProgressExceeded(++consecutiveNonProgress, activeRid); + continue; + } + if (errRid == QwpConstants.RequestIdWildcard) + { + Interlocked.Exchange(ref _transport, null)?.Dispose(); + MarkTerminal(); + } + else + { + _executeFinishedCleanly = true; + } + handler.OnError((QwpStatusCode)status, message); + return; + + case QwpEgressMsgKind.CacheReset: + DecodeCacheReset(payload); + ThrowIfNonProgressExceeded(++consecutiveNonProgress, activeRid); + break; + + case QwpEgressMsgKind.ServerInfo: + throw new IngressError(ErrorCode.ProtocolViolation, + "unexpected SERVER_INFO mid-query"); + + default: + throw new IngressError(ErrorCode.ProtocolViolation, + $"unknown egress frame 0x{(byte)kind:X2}"); + } + } + } + + private static void ThrowIfNonProgressExceeded(int consecutiveNonProgress, long activeRid) + { + if (consecutiveNonProgress > MaxConsecutiveNonProgressFrames) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"server sent {consecutiveNonProgress} consecutive frames not advancing request_id={activeRid}"); + } + } + + private async Task<(QwpEgressMsgKind Kind, ReadOnlyMemory Payload, byte HeaderFlags)> + ReadFrameAsync(CancellationToken ct) + { + var transport = _transport ?? throw new ObjectDisposedException(nameof(QwpQueryWebSocketClient)); + var (read, buffer) = await transport.ReceiveFrameAsync(_receiveBuffer, QwpConstants.MaxResultBatchWireBytes, ct) + .ConfigureAwait(false); + _receiveBuffer = buffer; + return SliceFrame(buffer, read); + } + + private static (QwpEgressMsgKind Kind, ReadOnlyMemory Payload, byte HeaderFlags) + SliceFrame(byte[] buffer, int read) + { + if (read < QwpConstants.HeaderSize) + { + throw new IngressError(ErrorCode.ProtocolViolation, $"frame shorter than QWP1 header: {read} bytes"); + } + + var hdr = buffer.AsSpan(0, QwpConstants.HeaderSize); + var magic = BinaryPrimitives.ReadUInt32LittleEndian(hdr.Slice(QwpConstants.OffsetMagic, 4)); + if (magic != QwpConstants.Magic) + { + throw new IngressError(ErrorCode.ProtocolViolation, $"bad QWP1 magic 0x{magic:X8}"); + } + + var version = hdr[QwpConstants.OffsetVersion]; + if (version != QwpConstants.SupportedVersion) + { + throw new IngressError(ErrorCode.ProtocolVersionError, $"unsupported QWP version {version}"); + } + + var flags = hdr[QwpConstants.OffsetFlags]; + var tableCount = BinaryPrimitives.ReadUInt16LittleEndian(hdr.Slice(QwpConstants.OffsetTableCount, 2)); + var payloadLenU32 = BinaryPrimitives.ReadUInt32LittleEndian(hdr.Slice(QwpConstants.OffsetPayloadLength, 4)); + if (payloadLenU32 > (uint)QwpConstants.MaxResultBatchWireBytes) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"payload_length {payloadLenU32} exceeds {QwpConstants.MaxResultBatchWireBytes}"); + } + var payloadLen = (int)payloadLenU32; + if (QwpConstants.HeaderSize + payloadLen != read) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"payload_length mismatch: header says {payloadLen}, frame is {read - QwpConstants.HeaderSize}"); + } + + if (payloadLen < 1) + { + throw new IngressError(ErrorCode.ProtocolViolation, "frame payload missing msg_kind byte"); + } + + var payloadMem = buffer.AsMemory(QwpConstants.HeaderSize, payloadLen); + var kind = (QwpEgressMsgKind)payloadMem.Span[0]; + var expectedTableCount = kind == QwpEgressMsgKind.ResultBatch ? 1 : 0; + if (tableCount != expectedTableCount) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"table_count={tableCount} for msg_kind 0x{(byte)kind:X2}, expected {expectedTableCount}"); + } + return (kind, payloadMem, flags); + } + + private async Task SendQueryRequestAsync( + long requestId, string sql, int sqlByteCount, long initialCredit, + ReadOnlyMemory bindBlob, int bindCount, CancellationToken ct) + { + var bindBlobSpan = bindBlob.Span; + var len = 1 + 8 + QwpVarint.GetByteCount((ulong)sqlByteCount) + sqlByteCount + + QwpVarint.GetByteCount((ulong)initialCredit) + + QwpVarint.GetByteCount((ulong)bindCount) + bindBlobSpan.Length; + + if (_queryRequestBuf.Length < len) + { + _queryRequestBuf = new byte[Math.Max(len, Math.Max(256, _queryRequestBuf.Length * 2))]; + } + var frame = _queryRequestBuf; + var p = 0; + frame[p++] = QwpConstants.MsgKindQueryRequest; + BinaryPrimitives.WriteInt64LittleEndian(frame.AsSpan(p, 8), requestId); + p += 8; + p += QwpVarint.Write(frame.AsSpan(p), (ulong)sqlByteCount); + StrictUtf8.GetBytes(sql, frame.AsSpan(p, sqlByteCount)); + p += sqlByteCount; + p += QwpVarint.Write(frame.AsSpan(p), (ulong)initialCredit); + p += QwpVarint.Write(frame.AsSpan(p), (ulong)bindCount); + if (bindBlobSpan.Length > 0) + { + bindBlobSpan.CopyTo(frame.AsSpan(p)); + } + + await SendFrameAsync(frame.AsMemory(0, len), ct).ConfigureAwait(false); + } + + private async Task SendFrameAsync(ReadOnlyMemory frame, CancellationToken ct) + { + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var transport = _transport ?? throw new ObjectDisposedException(nameof(QwpQueryWebSocketClient)); + await transport.SendBinaryAsync(frame, ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + private ReadOnlyMemory MaybeDecompressResultBatch(ReadOnlyMemory payload, byte headerFlags) + { + if ((headerFlags & QwpConstants.FlagZstd) == 0) return payload; + + var span = payload.Span; + if (span.Length < 1 + 8 + 1) + { + throw new IngressError(ErrorCode.ProtocolViolation, "compressed RESULT_BATCH missing prelude"); + } + + int seqBytes; + try + { + QwpVarint.Read(span.Slice(9), out seqBytes); + } + catch (Exception ex) + { + throw new IngressError(ErrorCode.ProtocolViolation, + "compressed RESULT_BATCH: malformed batch_seq varint", ex); + } + var preludeLen = 1 + 8 + seqBytes; + if (preludeLen > span.Length) + { + throw new IngressError(ErrorCode.ProtocolViolation, "compressed RESULT_BATCH prelude truncated"); + } + + var compressed = span.Slice(preludeLen); + if (compressed.IsEmpty) + { + throw new IngressError(ErrorCode.ProtocolViolation, "zstd RESULT_BATCH has empty compressed body"); + } + var declaredSize = ZstdSharp.Decompressor.GetDecompressedSize(compressed); + const ulong ContentSizeError = unchecked((ulong)-2L); + const ulong ContentSizeUnknown = unchecked((ulong)-1L); + if (declaredSize == ContentSizeError) + { + throw new IngressError(ErrorCode.ProtocolViolation, "zstd frame: malformed content-size"); + } + + int attemptSize; + if (declaredSize == ContentSizeUnknown) + { + // No declared size: decompress once into a worst-case destination. Oversized output + // overruns it and is rejected — no message-string-driven realloc retry loop. + attemptSize = QwpConstants.MaxResultBatchWireBytes; + } + else if (declaredSize > (ulong)QwpConstants.MaxResultBatchWireBytes) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"zstd frame reports decompressed size {declaredSize}, exceeds {QwpConstants.MaxResultBatchWireBytes}"); + } + else + { + attemptSize = (int)declaredSize; + } + + var needed = preludeLen + attemptSize; + if (_decompressBuffer.Length < needed) + { + _decompressBuffer = new byte[needed]; + } + span.Slice(0, preludeLen).CopyTo(_decompressBuffer); + + var decompressor = _decompressor ??= new ZstdSharp.Decompressor(); + int written; + try + { + written = decompressor.Unwrap(compressed, _decompressBuffer.AsSpan(preludeLen, attemptSize)); + } + catch (Exception ex) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"zstd RESULT_BATCH decompression failed (attemptSize={attemptSize}): {ex.Message}", ex); + } + + return _decompressBuffer.AsMemory(0, preludeLen + written); + } + + private async Task SendCreditAsync(long requestId, long additionalBytes, CancellationToken ct) + { + _creditFrameBuf[0] = QwpConstants.MsgKindCredit; + BinaryPrimitives.WriteInt64LittleEndian(_creditFrameBuf.AsSpan(1, 8), requestId); + var varintLen = QwpVarint.Write(_creditFrameBuf.AsSpan(9), (ulong)additionalBytes); + var len = 1 + 8 + varintLen; + + if (_transport is null) return; + await SendFrameAsync(_creditFrameBuf.AsMemory(0, len), ct).ConfigureAwait(false); + } + + private async Task CancelAndDrainAsync(long requestId) + { + try { await SendCancelAsync(requestId, CancellationToken.None).ConfigureAwait(false); } + catch { return false; } + + const int maxDrainFrames = 1024; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + for (var i = 0; i < maxDrainFrames; i++) + { + QwpEgressMsgKind kind; + ReadOnlyMemory payload; + byte headerFlags; + try + { + (kind, payload, headerFlags) = await ReadFrameAsync(cts.Token).ConfigureAwait(false); + } + catch + { + return false; + } + + if (kind is QwpEgressMsgKind.ResultEnd + or QwpEgressMsgKind.QueryError + or QwpEgressMsgKind.ExecDone) + { + return true; + } + + if (kind == QwpEgressMsgKind.CacheReset) + { + DecodeCacheReset(payload); + continue; + } + + if (kind == QwpEgressMsgKind.ResultBatch) + { + // Fully decode so dict/schema cursors stay in sync with the server; skip OnBatch. + try + { + var decoded = MaybeDecompressResultBatch(payload, headerFlags); + _decoder.Decode(decoded.Span, headerFlags, _batch); + } + catch + { + return false; + } + continue; + } + + return false; + } + return false; + } + + private async Task SendCancelAsync(long requestId, CancellationToken ct) + { + await _sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (Interlocked.Read(ref _currentRequestId) != requestId) return; + var transport = _transport; + if (transport is null) return; + _cancelFrameBuf[0] = QwpConstants.MsgKindCancel; + BinaryPrimitives.WriteInt64LittleEndian(_cancelFrameBuf.AsSpan(1, 8), requestId); + await transport.SendBinaryAsync(_cancelFrameBuf, ct).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + // StrictUtf8 throws DecoderFallbackException on invalid bytes; remap to ProtocolViolation so a + // malformed server frame surfaces through the egress error contract rather than as a raw BCL type. + private static string DecodeUtf8(ReadOnlySpan bytes, string field) + { + try + { + return StrictUtf8.GetString(bytes); + } + catch (DecoderFallbackException ex) + { + throw new IngressError(ErrorCode.ProtocolViolation, $"{field} is not valid UTF-8", ex); + } + } + + private static QwpServerInfo DecodeServerInfo(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 1 + 8 + 4 + 8 + 2) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"SERVER_INFO too short: {s.Length} bytes"); + } + + if (s[0] != QwpConstants.MsgKindServerInfo) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"SERVER_INFO has wrong msg_kind 0x{s[0]:X2}"); + } + + var role = s[1]; + var epoch = BinaryPrimitives.ReadUInt64LittleEndian(s.Slice(2, 8)); + var capabilities = BinaryPrimitives.ReadUInt32LittleEndian(s.Slice(10, 4)); + var serverWallNs = BinaryPrimitives.ReadInt64LittleEndian(s.Slice(14, 8)); + + var clusterIdLen = BinaryPrimitives.ReadUInt16LittleEndian(s.Slice(22, 2)); + if (s.Length < 24 + clusterIdLen + 2) + { + throw new IngressError(ErrorCode.ProtocolViolation, "SERVER_INFO truncated at cluster_id"); + } + var clusterId = DecodeUtf8(s.Slice(24, clusterIdLen), "SERVER_INFO cluster_id"); + var nodeIdLenOffset = 24 + clusterIdLen; + var nodeIdLen = BinaryPrimitives.ReadUInt16LittleEndian(s.Slice(nodeIdLenOffset, 2)); + var nodeIdStart = nodeIdLenOffset + 2; + if (s.Length < nodeIdStart + nodeIdLen) + { + throw new IngressError(ErrorCode.ProtocolViolation, "SERVER_INFO truncated at node_id"); + } + var nodeId = DecodeUtf8(s.Slice(nodeIdStart, nodeIdLen), "SERVER_INFO node_id"); + var consumed = nodeIdStart + nodeIdLen; + + string? zoneId = null; + if ((capabilities & QwpConstants.CapZone) != 0) + { + if (s.Length < consumed + 2) + { + throw new IngressError(ErrorCode.ProtocolViolation, + "SERVER_INFO truncated at zone_id_len (CAP_ZONE set)"); + } + var zoneIdLen = BinaryPrimitives.ReadUInt16LittleEndian(s.Slice(consumed, 2)); + consumed += 2; + if (s.Length < consumed + zoneIdLen) + { + throw new IngressError(ErrorCode.ProtocolViolation, "SERVER_INFO truncated at zone_id"); + } + zoneId = DecodeUtf8(s.Slice(consumed, zoneIdLen), "SERVER_INFO zone_id"); + consumed += zoneIdLen; + } + + if (s.Length < consumed) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"SERVER_INFO truncated: consumed {consumed}, payload {s.Length}"); + } + + return new QwpServerInfo + { + Role = (QwpRole)role, + Epoch = epoch, + Capabilities = capabilities, + ServerWallNs = serverWallNs, + ClusterId = clusterId, + NodeId = nodeId, + ZoneId = zoneId, + }; + } + + // QwpVarint.Read (shared with the ingest parser) reports a malformed varint as the retryable + // ProtocolVersionError; inside a fixed-size terminator frame that's structural corruption, so + // remap to ProtocolViolation — matching this file's other terminator guards and the RESULT_BATCH + // decoder, so the query loop fails terminal instead of retrying a deterministically-corrupt frame. + private static ulong ReadTerminatorVarint(ReadOnlySpan src, out int bytesRead, string context) + { + try + { + return QwpVarint.Read(src, out bytesRead); + } + catch (IngressError ex) + { + throw new IngressError(ErrorCode.ProtocolViolation, context, ex); + } + } + + private static (long RequestId, long TotalRows) DecodeResultEnd(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 8) throw new IngressError(ErrorCode.ProtocolViolation, "RESULT_END too short"); + var requestId = BinaryPrimitives.ReadInt64LittleEndian(s.Slice(1, 8)); + var p = 9; + ReadTerminatorVarint(s.Slice(p), out var consumed1, "RESULT_END malformed final_seq varint"); // final_seq is informational; not surfaced. + p += consumed1; + var totalRows = (long)ReadTerminatorVarint(s.Slice(p), out var consumed2, "RESULT_END malformed total_rows varint"); + p += consumed2; + if (p != s.Length) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"RESULT_END trailing bytes: consumed {p}, payload {s.Length}"); + } + return (requestId, totalRows); + } + + private static (long RequestId, byte OpType, long RowsAffected) DecodeExecDone(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 8 + 1 + 1) throw new IngressError(ErrorCode.ProtocolViolation, "EXEC_DONE too short"); + var requestId = BinaryPrimitives.ReadInt64LittleEndian(s.Slice(1, 8)); + byte opType = s[9]; + var rowsAffectedRaw = ReadTerminatorVarint(s.Slice(10), out var consumed, "EXEC_DONE malformed rows_affected varint"); + if (rowsAffectedRaw > long.MaxValue) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"EXEC_DONE rows_affected {rowsAffectedRaw} exceeds Int64 range"); + } + var rowsAffected = (long)rowsAffectedRaw; + var p = 10 + consumed; + if (p != s.Length) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"EXEC_DONE trailing bytes: consumed {p}, payload {s.Length}"); + } + return (requestId, opType, rowsAffected); + } + + private static (long RequestId, byte Status, string Message) DecodeQueryError(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 8 + 1 + 2) + { + throw new IngressError(ErrorCode.ProtocolViolation, "QUERY_ERROR too short"); + } + var requestId = BinaryPrimitives.ReadInt64LittleEndian(s.Slice(1, 8)); + var status = s[9]; + var msgLen = BinaryPrimitives.ReadUInt16LittleEndian(s.Slice(10, 2)); + if (s.Length != 12 + msgLen) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"QUERY_ERROR length mismatch: msgLen={msgLen} payload={s.Length}"); + } + var msg = DecodeUtf8(s.Slice(12, msgLen), "QUERY_ERROR message"); + return (requestId, status, msg); + } + + private void DecodeCacheReset(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length != 2) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"CACHE_RESET length mismatch: payload={s.Length}, expected 2"); + } + if (s[0] != QwpConstants.MsgKindCacheReset) + { + throw new IngressError(ErrorCode.ProtocolViolation, + $"CACHE_RESET has wrong msg_kind 0x{s[0]:X2}"); + } + var mask = s[1]; + // Only bit 0 (SYMBOL dict) is defined; other bits are reserved and silently ignored. + if ((mask & QwpConstants.ResetMaskDict) != 0) _connState.ResetSymbolDict(); + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(QwpQueryWebSocketClient)); + } + } + + private void ThrowIfTerminal() + { + if (Volatile.Read(ref _terminal) != 0) + { + throw new IngressError(ErrorCode.SocketError, + "client is in a terminal state after a prior failure; create a new QueryClient"); + } + } + + private void MarkTerminal() => Volatile.Write(ref _terminal, 1); +} + +#endif diff --git a/src/net-questdb-client/Qwp/Query/QwpResultBatchDecoder.cs b/src/net-questdb-client/Qwp/Query/QwpResultBatchDecoder.cs new file mode 100644 index 00000000..af4ac285 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpResultBatchDecoder.cs @@ -0,0 +1,655 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Query; + +/// +/// Decodes RESULT_BATCH payloads into a reusable . +/// +/// +/// Caller has already parsed the 12-byte QWP1 frame header and routed the message kind. +/// Caller passes the post-header payload (starting at msg_kind=0x11) and the header +/// flags byte so the decoder can interpret and +/// . +/// +internal sealed class QwpResultBatchDecoder +{ + private static readonly UTF8Encoding StrictUtf8 = QwpConstants.StrictUtf8; + + private readonly QwpEgressConnState _state; + + // Per-query column schema captured from the first batch (batch_seq == 0). Continuation + // batches bind rows to it. Reused across queries by reassignment; ResetQuerySchema() + // invalidates it so a stray continuation can't bind to a stale schema after a new + // query starts. Single slot is correct because the query client serialises queries + // (one in-flight at a time); if pipelining is ever added this must become per-request_id. + private EgressSchema? _querySchema; + private bool _querySchemaValid; + + public QwpResultBatchDecoder(QwpEgressConnState state) + { + _state = state; + } + + /// + /// Invalidates the schema captured from the last batch_seq == 0. The query client + /// calls this when a new query starts so the next query's continuation batches can't bind + /// rows to a stale schema. + /// + public void ResetQuerySchema() + { + _querySchemaValid = false; + _querySchema = null; + } + + public void Decode(ReadOnlySpan payload, byte headerFlags, QwpColumnBatch batch) + { + var p = 0; + if (payload.Length < 1 + 8) + { + throw new QwpDecodeException("RESULT_BATCH payload too short for prelude"); + } + + var msgKind = payload[p++]; + if (msgKind != QwpConstants.MsgKindResultBatch) + { + throw new QwpDecodeException($"expected RESULT_BATCH (0x11), got 0x{msgKind:X2}"); + } + + var requestId = BinaryPrimitives.ReadInt64LittleEndian(payload.Slice(p, 8)); + p += 8; + var batchSeq = ReadVarint(payload, ref p); + + var preDictSize = _state.SymbolDict.Size; + var preSchemaValid = _querySchemaValid; + var preSchema = _querySchema; + var commit = false; + try + { + if ((headerFlags & QwpConstants.FlagDeltaSymbolDict) != 0) + { + DecodeDeltaSymbolDict(payload, ref p); + } + + batch.Reset(); + batch.RequestId = requestId; + batch.BatchSeq = (long)batchSeq; + + DecodeTableBlock(payload, ref p, headerFlags, batch, (long)batchSeq); + + if (p != payload.Length) + { + throw new QwpDecodeException($"trailing bytes after RESULT_BATCH: consumed {p}, payload {payload.Length}"); + } + + commit = true; + } + finally + { + if (!commit) + { + // Symbols already appended into the dict; rewind to the pre-batch cursor on failure. + _state.SymbolDict.TruncateTo(preDictSize); + // Same for the per-query schema slot: a partial parse of a batch_seq==0 must not + // leave the decoder with a half-built schema bound for subsequent continuation batches. + _querySchemaValid = preSchemaValid; + _querySchema = preSchema; + } + } + } + + private void DecodeDeltaSymbolDict(ReadOnlySpan payload, ref int p) + { + var deltaStart = ReadBoundedVarintAsInt(payload, ref p, "symbol dict deltaStart"); + var deltaCount = ReadBoundedVarintAsInt(payload, ref p, "symbol dict deltaCount"); + + if (deltaStart != _state.SymbolDict.Size) + { + throw new QwpDecodeException( + $"symbol dict deltaStart={deltaStart} disagrees with client cursor {_state.SymbolDict.Size}"); + } + + if ((long)deltaStart + deltaCount > int.MaxValue) + { + throw new QwpDecodeException( + $"symbol dict deltaStart+deltaCount overflows: {deltaStart}+{deltaCount}"); + } + + for (var i = 0; i < deltaCount; i++) + { + var len = ReadBoundedVarintAsInt(payload, ref p, "symbol dict entry length"); + if (len > QwpConstants.MaxResultBatchWireBytes) + { + throw new QwpDecodeException($"symbol dict entry length out of range: {len}"); + } + if (len > payload.Length - p) + { + throw new QwpDecodeException("truncated symbol dict entry"); + } + _state.SymbolDict.AppendEntry(payload.Slice(p, len)); + p += len; + } + } + + private static int ReadBoundedVarintAsInt(ReadOnlySpan payload, ref int p, string field) + { + var v = ReadVarint(payload, ref p); + if (v > int.MaxValue) + { + throw new QwpDecodeException($"{field} varint exceeds int.MaxValue: {v}"); + } + return (int)v; + } + + private void DecodeTableBlock( + ReadOnlySpan payload, ref int p, byte headerFlags, QwpColumnBatch batch, long batchSeq) + { + var nameLen = ReadBoundedVarintAsInt(payload, ref p, "table name length"); + if (nameLen > QwpConstants.MaxNameLengthBytes) + { + throw new QwpDecodeException($"table name length out of range: {nameLen}"); + } + if (nameLen > payload.Length - p) + { + throw new QwpDecodeException("truncated table name"); + } + p += nameLen; + + var rowCount = ReadBoundedVarintAsInt(payload, ref p, "row_count"); + if (rowCount > QwpConstants.MaxRowsPerTable) + { + throw new QwpDecodeException($"row_count out of range: {rowCount}"); + } + + EgressSchema schema; + int colCount; + if (batchSeq == 0) + { + // First batch of a query carries the inline schema: col_count then one + // (name_length, name, wire_type) descriptor per column. + colCount = ReadBoundedVarintAsInt(payload, ref p, "col_count"); + if (colCount > QwpConstants.MaxColumnsPerTable) + { + throw new QwpDecodeException($"col_count out of range: {colCount}"); + } + + var defs = new EgressColumnDef[colCount]; + for (var i = 0; i < colCount; i++) + { + var cnLen = ReadBoundedVarintAsInt(payload, ref p, "column name length"); + if (cnLen > QwpConstants.MaxNameLengthBytes) + { + throw new QwpDecodeException($"column name length out of range: {cnLen}"); + } + if (cnLen > payload.Length - p) + { + throw new QwpDecodeException("truncated column name"); + } + string name; + try + { + name = StrictUtf8.GetString(payload.Slice(p, cnLen)); + } + catch (DecoderFallbackException ex) + { + throw new QwpDecodeException("column name is not valid UTF-8", ex); + } + p += cnLen; + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated before column type code"); + } + var typeCode = (QwpTypeCode)payload[p++]; + defs[i] = new EgressColumnDef(name, typeCode); + } + schema = new EgressSchema(defs); + _querySchema = schema; + _querySchemaValid = true; + } + else + { + // Continuation batch: bind rows to the schema delivered on batch_seq == 0. + // A continuation arriving before any schema-bearing batch (malformed or hostile + // server) must not be allowed to bind rows to a stale schema. + if (!_querySchemaValid || _querySchema is null) + { + throw new QwpDecodeException( + $"RESULT_BATCH batch_seq={batchSeq} arrived before the schema-bearing batch_seq=0"); + } + schema = _querySchema; + colCount = schema.Columns.Length; + } + + batch.RowCount = rowCount; + batch.TrimToColumnCount(colCount); + for (var i = 0; i < colCount; i++) + { + var def = schema.Columns[i]; + batch.ConfigureColumn(i, def.Name, def.TypeCode, scale: 0, precisionBits: 0); + } + + var gorillaEnabled = (headerFlags & QwpConstants.FlagGorilla) != 0; + for (var i = 0; i < colCount; i++) + { + DecodeColumnData(payload, ref p, batch.GetColumn(i), rowCount, gorillaEnabled); + } + } + + private void DecodeColumnData( + ReadOnlySpan payload, ref int p, ColumnView col, int rowCount, bool gorillaEnabled) + { + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated before null_flag"); + } + + var nullFlag = payload[p++]; + int nonNull; + int[]? nonNullIndex = null; + if (nullFlag == 0) + { + nonNull = rowCount; + } + else + { + var bitmapBytes = (rowCount + 7) >> 3; + if (bitmapBytes > payload.Length - p) + { + throw new QwpDecodeException("truncated null bitmap"); + } + if (col.NonNullIndexBuf.Length < rowCount) + { + col.NonNullIndexBuf = new int[Math.Max(rowCount, Math.Max(64, col.NonNullIndexBuf.Length * 2))]; + } + nonNull = BuildNonNullIndex(payload.Slice(p, bitmapBytes), rowCount, col.NonNullIndexBuf); + nonNullIndex = col.NonNullIndexBuf; + p += bitmapBytes; + } + + col.NonNullIndex = nonNullIndex; + + switch (col.TypeCode) + { + case QwpTypeCode.Boolean: + CopyFixed(payload, ref p, col, (nonNull + 7) >> 3); + break; + + case QwpTypeCode.Byte: + CopyFixed(payload, ref p, col, nonNull); + break; + + case QwpTypeCode.Short: + case QwpTypeCode.Char: + CopyFixed(payload, ref p, col, nonNull * 2); + break; + + case QwpTypeCode.Int: + case QwpTypeCode.IPv4: + case QwpTypeCode.Float: + CopyFixed(payload, ref p, col, nonNull * 4); + break; + + case QwpTypeCode.Long: + case QwpTypeCode.Double: + CopyFixed(payload, ref p, col, nonNull * 8); + break; + + case QwpTypeCode.Uuid: + CopyFixed(payload, ref p, col, nonNull * 16); + break; + + case QwpTypeCode.Long256: + CopyFixed(payload, ref p, col, nonNull * QwpConstants.Long256SizeBytes); + break; + + case QwpTypeCode.Date: + case QwpTypeCode.Timestamp: + case QwpTypeCode.TimestampNanos: + DecodeTimestampColumn(payload, ref p, col, nonNull, gorillaEnabled); + break; + + case QwpTypeCode.Varchar: + case QwpTypeCode.Binary: + DecodeStringColumn(payload, ref p, col, nonNull); + break; + + case QwpTypeCode.Symbol: + DecodeSymbolColumn(payload, ref p, col, nonNull); + break; + + case QwpTypeCode.Decimal64: + DecodeDecimalColumn(payload, ref p, col, nonNull, valueBytes: QwpConstants.Decimal64SizeBytes); + break; + + case QwpTypeCode.Decimal128: + DecodeDecimalColumn(payload, ref p, col, nonNull, valueBytes: QwpConstants.Decimal128SizeBytes); + break; + + case QwpTypeCode.Decimal256: + DecodeDecimalColumn(payload, ref p, col, nonNull, valueBytes: QwpConstants.Decimal256SizeBytes); + break; + + case QwpTypeCode.Geohash: + DecodeGeohashColumn(payload, ref p, col, nonNull); + break; + + case QwpTypeCode.DoubleArray: + case QwpTypeCode.LongArray: + DecodeArrayColumn(payload, ref p, col, nonNull, elementBytes: 8); + break; + + default: + throw new QwpDecodeException($"unsupported column type 0x{(byte)col.TypeCode:X2}"); + } + } + + private void DecodeTimestampColumn( + ReadOnlySpan payload, ref int p, ColumnView col, int nonNull, bool gorillaEnabled) + { + if (!gorillaEnabled) + { + CopyFixed(payload, ref p, col, nonNull * 8); + return; + } + + var rawBytes = nonNull * 8; + col.ValueBytes = RentScratch(col.ValueBytes, Math.Max(rawBytes, 0)); + + if (nonNull == 0) + { + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated before timestamp encoding flag"); + } + var flag = payload[p++]; + if (flag != 0x00) + { + throw new QwpDecodeException( + $"timestamp encoding flag 0x{flag:X2} invalid for nonNull=0; only 0x00 (raw) is valid"); + } + return; + } + + // BE-safe: writes LE bytes; the Span overload would byte-swap on big-endian readers. + int consumed; + try + { + consumed = QwpGorilla.DecodeToBytes(payload.Slice(p), col.ValueBytes.AsSpan(0, rawBytes), nonNull); + } + catch (Exception ex) when (ex is IngressError or InvalidOperationException) + { + // QwpGorilla (shared with ingest) reports corruption as the retryable ProtocolVersionError, + // or throws InvalidOperationException from QwpBitReader on a truncated DoD bitstream. Both + // are terminal RESULT_BATCH corruption — remap so the query loop sees ProtocolViolation. + throw new QwpDecodeException("malformed Gorilla timestamp column in RESULT_BATCH", ex); + } + p += consumed; + } + + private void DecodeStringColumn(ReadOnlySpan payload, ref int p, ColumnView col, int nonNull) + { + var offsetBytes = (nonNull + 1) * 4; + if (offsetBytes > payload.Length - p) + { + throw new QwpDecodeException("truncated varchar offsets"); + } + + if (col.StringOffsetsBuf.Length < nonNull + 1) + { + col.StringOffsetsBuf = new int[Math.Max(nonNull + 1, Math.Max(64, col.StringOffsetsBuf.Length * 2))]; + } + var offsets = col.StringOffsetsBuf; + for (var i = 0; i <= nonNull; i++) + { + offsets[i] = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(p, 4)); + p += 4; + } + + if (offsets[0] != 0) + { + throw new QwpDecodeException($"varchar offsets[0] must be 0, got {offsets[0]}"); + } + + var heapLen = nonNull > 0 ? offsets[nonNull] : 0; + if (heapLen < 0 || heapLen > payload.Length - p) + { + throw new QwpDecodeException("truncated varchar heap"); + } + + var prev = 0; + for (var i = 1; i <= nonNull; i++) + { + var off = offsets[i]; + if (off < prev || off > heapLen) + { + throw new QwpDecodeException( + $"varchar offsets non-monotonic or out of range at index {i}: prev={prev} off={off} heapLen={heapLen}"); + } + prev = off; + } + + col.StringOffsets = offsets; + col.StringHeap = RentScratch(col.StringHeap, heapLen); + if (heapLen > 0) + { + payload.Slice(p, heapLen).CopyTo(col.StringHeap); + p += heapLen; + } + } + + private void DecodeSymbolColumn(ReadOnlySpan payload, ref int p, ColumnView col, int nonNull) + { + if (col.SymbolIdsBuf.Length < Math.Max(nonNull, 1)) + { + col.SymbolIdsBuf = new int[Math.Max(nonNull, Math.Max(64, col.SymbolIdsBuf.Length * 2))]; + } + col.SymbolIds = col.SymbolIdsBuf; + col.SymbolDict = _state.SymbolDict; + + var dictSize = _state.SymbolDict.Size; + for (var i = 0; i < nonNull; i++) + { + var id = ReadBoundedVarintAsInt(payload, ref p, "symbol id"); + if ((uint)id >= (uint)dictSize) + { + throw new QwpDecodeException( + $"symbol id {id} out of range [0, {dictSize})"); + } + col.SymbolIdsBuf[i] = id; + } + } + + private void DecodeDecimalColumn( + ReadOnlySpan payload, ref int p, ColumnView col, int nonNull, int valueBytes) + { + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated before decimal scale prefix"); + } + var scale = payload[p++]; + SetScale(col, scale); + CopyFixed(payload, ref p, col, nonNull * valueBytes); + } + + private void DecodeGeohashColumn(ReadOnlySpan payload, ref int p, ColumnView col, int nonNull) + { + var precisionBits = (int)ReadVarint(payload, ref p); + if (precisionBits < QwpConstants.MinGeohashPrecisionBits + || precisionBits > QwpConstants.MaxGeohashPrecisionBits) + { + throw new QwpDecodeException($"geohash precision out of range: {precisionBits}"); + } + SetPrecision(col, (byte)precisionBits); + var stride = (precisionBits + 7) >> 3; + CopyFixed(payload, ref p, col, nonNull * stride); + } + + private void CopyFixed(ReadOnlySpan payload, ref int p, ColumnView col, int byteCount) + { + if (byteCount < 0 || byteCount > payload.Length - p) + { + throw new QwpDecodeException( + $"truncated column data: need {byteCount} bytes, payload has {payload.Length - p}"); + } + col.ValueBytes = RentScratch(col.ValueBytes, byteCount); + if (byteCount > 0) + { + payload.Slice(p, byteCount).CopyTo(col.ValueBytes); + p += byteCount; + } + } + + // Plain arrays, not ArrayPool: the per-column scratch is grown-and-reused across every batch, + // so pooling buys almost nothing — and the final buffers are never returned, which would + // otherwise drain ArrayPool.Shared of large arrays for each disposed query client. + private byte[] RentScratch(byte[] existing, int needed) + { + if (existing.Length >= needed) return existing; + var cap = Math.Max(needed, Math.Max(64, existing.Length * 2)); + return new byte[cap]; + } + + private static int BuildNonNullIndex(ReadOnlySpan bitmap, int rowCount, int[] index) + { + var nonNull = 0; + for (var r = 0; r < rowCount; r++) + { + var bit = (bitmap[r >> 3] >> (r & 7)) & 1; + if (bit == 1) + { + index[r] = -1; + } + else + { + index[r] = nonNull++; + } + } + return nonNull; + } + + private static ulong ReadVarint(ReadOnlySpan payload, ref int p) + { + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated varint"); + } + int consumed; + ulong v; + try + { + v = QwpVarint.Read(payload.Slice(p), out consumed); + } + catch (IngressError ex) + { + // QwpVarint.Read (shared with the ingest parser) reports a malformed varint as the + // retryable ProtocolVersionError; inside a RESULT_BATCH it's terminal frame corruption. + // Remap so the query loop sees ProtocolViolation and won't retry a corrupt frame. + throw new QwpDecodeException("malformed varint in RESULT_BATCH", ex); + } + p += consumed; + return v; + } + + private static void SetScale(ColumnView col, byte scale) => col.Scale = scale; + private static void SetPrecision(ColumnView col, byte precisionBits) => col.PrecisionBits = precisionBits; + + private void DecodeArrayColumn(ReadOnlySpan payload, ref int p, ColumnView col, int nonNull, int elementBytes) + { + if (col.StringOffsetsBuf.Length < nonNull + 1) + { + col.StringOffsetsBuf = new int[Math.Max(nonNull + 1, Math.Max(64, col.StringOffsetsBuf.Length * 2))]; + } + var offsets = col.StringOffsetsBuf; + var heapStart = p; + for (var i = 0; i < nonNull; i++) + { + offsets[i] = p - heapStart; + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated array row: missing nDims"); + } + int nDims = payload[p]; + p++; + if (nDims < 1 || nDims > QwpConstants.MaxArrayDimensions) + { + throw new QwpDecodeException( + $"array nDims out of range: {nDims} (must be in [1, {QwpConstants.MaxArrayDimensions}])"); + } + + var dimsBytes = nDims * 4; + if (dimsBytes > payload.Length - p) + { + throw new QwpDecodeException("truncated array row: dim header overflow"); + } + + long elementCount = 1; + var maxElements = (long)((payload.Length - p - dimsBytes) / elementBytes); + for (var d = 0; d < nDims; d++) + { + var dim = BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(p + d * 4, 4)); + if (dim < 0) + { + throw new QwpDecodeException($"array dim {d} negative ({dim})"); + } + if (dim != 0 && elementCount > maxElements / dim) + { + throw new QwpDecodeException( + $"array shape exceeds remaining payload: dim {d} = {dim}"); + } + elementCount *= dim; + } + p += dimsBytes; + + var valueBytes = elementCount * elementBytes; + if (valueBytes < 0 || valueBytes > payload.Length - p) + { + throw new QwpDecodeException("truncated array row: values overflow"); + } + p += (int)valueBytes; + + // The span accessors read [u8 nDims][i32 dims][values]; assert the row carries at + // least the nDims byte + dim header so a later MemoryMarshal.Cast can't underflow. + var rowLen = (p - heapStart) - offsets[i]; + if (rowLen < 1 + nDims * 4) + { + throw new QwpDecodeException( + $"array row {i} shorter than its header: {rowLen} bytes, need {1 + nDims * 4}"); + } + } + offsets[nonNull] = p - heapStart; + + var heapLen = p - heapStart; + col.ValueBytes = RentScratch(col.ValueBytes, heapLen); + if (heapLen > 0) + { + payload.Slice(heapStart, heapLen).CopyTo(col.ValueBytes); + } + col.StringOffsets = offsets; + } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpRoleMismatchException.cs b/src/net-questdb-client/Qwp/Query/QwpRoleMismatchException.cs new file mode 100644 index 00000000..f799bf55 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpRoleMismatchException.cs @@ -0,0 +1,65 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Query; + +/// +/// Thrown by connect() when no configured endpoint matches the requested +/// target=any|primary|replica filter. The property carries +/// the last server's SERVER_INFO so callers can distinguish "no primary available" from +/// "all endpoints unreachable". +/// +public sealed class QwpRoleMismatchException : IngressError +{ + /// + /// Constructs the exception with the requested role filter, the most recent + /// SERVER_INFO observed (if any), and a human-readable message. + /// + public QwpRoleMismatchException(TargetType target, QwpServerInfo? lastObserved, string message) + : base(ErrorCode.ConfigError, message) + { + Target = target; + LastObserved = lastObserved; + } + + /// + /// Constructs the exception chaining the underlying cause (for example, the transport error + /// observed against the last candidate endpoint). + /// + public QwpRoleMismatchException(TargetType target, QwpServerInfo? lastObserved, string message, Exception inner) + : base(ErrorCode.ConfigError, message, inner) + { + Target = target; + LastObserved = lastObserved; + } + + /// The role filter the caller requested when establishing the connection. + public TargetType Target { get; } + + /// The last server's SERVER_INFO if any was received, otherwise null. + public QwpServerInfo? LastObserved { get; } +} diff --git a/src/net-questdb-client/Qwp/Query/QwpServerInfo.cs b/src/net-questdb-client/Qwp/Query/QwpServerInfo.cs new file mode 100644 index 00000000..b22e6340 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpServerInfo.cs @@ -0,0 +1,60 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; + +namespace QuestDB.Qwp.Query; + +/// Decoded SERVER_INFO frame (v2 only). +public sealed class QwpServerInfo +{ + /// Server role; unrecognised wire codes surface as an unnamed value. + public QwpRole Role { get; init; } + /// Server epoch advanced on every primary/replica failover; lets the client detect topology changes. + public ulong Epoch { get; init; } + /// Bitmask of optional features advertised by the server. + public uint Capabilities { get; init; } + /// Server wall clock at the time the frame was emitted, in nanoseconds since Unix epoch. + public long ServerWallNs { get; init; } + /// Cluster identifier (stable across primary/replica failover). + public string ClusterId { get; init; } = string.Empty; + /// Node identifier within the cluster. + public string NodeId { get; init; } = string.Empty; + /// + /// Server-advertised zone identifier; non-null only when has the + /// bit set. Compared case-insensitively against the client's + /// configured zone= for same-zone routing preference. + /// + public string? ZoneId { get; init; } + + /// Human-readable name of ; UNKNOWN(n) for unrecognised codes. + public string RoleName => Role switch + { + QwpRole.Standalone => "STANDALONE", + QwpRole.Primary => "PRIMARY", + QwpRole.Replica => "REPLICA", + QwpRole.PrimaryCatchup => "PRIMARY_CATCHUP", + _ => $"UNKNOWN({(byte)Role})", + }; +} diff --git a/src/net-questdb-client/Qwp/QwpAuthFailedException.cs b/src/net-questdb-client/Qwp/QwpAuthFailedException.cs new file mode 100644 index 00000000..0ad42b89 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpAuthFailedException.cs @@ -0,0 +1,74 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Raised when the WebSocket upgrade is rejected by the server with an authentication-related +/// status (HTTP 401 or 403). Distinct from generic so callers can +/// handle credential failures separately from transport/protocol errors. +/// +/// +/// Inherits with , so existing +/// catch (IngressError) when (e.code == ErrorCode.AuthError) handlers keep matching. +/// Auth failures are terminal — failover does not retry rejected credentials, since flooding +/// the server with the same bad credentials rarely recovers and pollutes server logs. +/// +public sealed class QwpAuthFailedException : IngressError +{ + /// HTTP status code returned by the server on the WebSocket upgrade response. + public int HttpStatusCode { get; } + + /// The endpoint the auth attempt was made against; null when not available. + public Uri? Endpoint { get; } + + /// Constructs a new from the rejecting endpoint and HTTP status. + public QwpAuthFailedException(int httpStatusCode, Uri? endpoint) + : base(ErrorCode.AuthError, BuildMessage(httpStatusCode, endpoint)) + { + HttpStatusCode = httpStatusCode; + Endpoint = endpoint; + } + + /// + /// Constructs a new from the rejecting endpoint and HTTP + /// status, chaining the underlying exception. + /// + public QwpAuthFailedException(int httpStatusCode, Uri? endpoint, Exception inner) + : base(ErrorCode.AuthError, BuildMessage(httpStatusCode, endpoint), inner) + { + HttpStatusCode = httpStatusCode; + Endpoint = endpoint; + } + + private static string BuildMessage(int status, Uri? endpoint) + { + return endpoint is null + ? $"WebSocket upgrade rejected with HTTP {status}" + : $"WebSocket upgrade rejected with HTTP {status} for {endpoint}"; + } +} diff --git a/src/net-questdb-client/Qwp/QwpBitWriter.cs b/src/net-questdb-client/Qwp/QwpBitWriter.cs new file mode 100644 index 00000000..0ea4d02d --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpBitWriter.cs @@ -0,0 +1,227 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Runtime.CompilerServices; + +namespace QuestDB.Qwp; + +/// +/// Writes a bit-packed stream LSB-first within each byte. +/// +/// +/// The first written bit lands in bit 0 of byte 0; subsequent bits fill bit 1, bit 2, … of the +/// same byte before advancing to the next. A partially-filled trailing byte is padded with +/// zeros at . +/// +/// ref struct for stack allocation; cannot escape the calling method or be stored in a +/// field. Construct one per encode pass. +/// +internal ref struct QwpBitWriter +{ + private readonly Span _buffer; + private readonly int _startOffset; + private int _byteIndex; + private int _bitIndex; + + public QwpBitWriter(Span buffer, int startOffset) + { + if ((uint)startOffset > (uint)buffer.Length) + { + throw new ArgumentOutOfRangeException(nameof(startOffset)); + } + + _buffer = buffer; + _startOffset = startOffset; + _byteIndex = startOffset; + _bitIndex = 0; + if (_byteIndex < _buffer.Length) + { + // Zero the first byte so subsequent OR operations are well-defined regardless of any + // stale bytes the buffer carries from prior frames. + _buffer[_byteIndex] = 0; + } + } + + /// + /// Writes the low bits of , LSB-first. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteBits(ulong value, int bitCount) + { + if ((uint)bitCount > 64) + { + throw new ArgumentOutOfRangeException(nameof(bitCount)); + } + + if (bitCount == 0) return; + + // Upfront capacity check — without it, all-zero bitstreams silently advance past the end. + var endByte = _byteIndex + (_bitIndex + bitCount + 7) / 8; + if (endByte > _buffer.Length) + { + throw new InvalidOperationException("bit writer exhausted"); + } + + if (bitCount < 64) + { + value &= (1UL << bitCount) - 1UL; + } + + var remaining = bitCount; + + if (_bitIndex != 0) + { + var roomInByte = 8 - _bitIndex; + var take = remaining < roomInByte ? remaining : roomInByte; + var headMask = (1UL << take) - 1UL; + _buffer[_byteIndex] |= (byte)((value & headMask) << _bitIndex); + value >>= take; + remaining -= take; + _bitIndex += take; + if (_bitIndex == 8) + { + _byteIndex++; + _bitIndex = 0; + if (_byteIndex < _buffer.Length) + { + _buffer[_byteIndex] = 0; + } + } + } + + while (remaining >= 8) + { + _buffer[_byteIndex] = (byte)value; + value >>= 8; + _byteIndex++; + remaining -= 8; + if (_byteIndex < _buffer.Length) + { + _buffer[_byteIndex] = 0; + } + } + + if (remaining > 0) + { + var tailMask = (1UL << remaining) - 1UL; + _buffer[_byteIndex] |= (byte)(value & tailMask); + _bitIndex = remaining; + } + } + + /// + /// Pads the current byte with zeros up to the next byte boundary and returns the total + /// number of bytes written since construction. + /// + public int FinishToByteBoundary() + { + var bytes = _byteIndex - _startOffset; + if (_bitIndex > 0) + { + bytes++; + } + + return bytes; + } +} + +/// +/// Reads a bit-packed stream LSB-first within each byte; mirror of . +/// +internal ref struct QwpBitReader +{ + private readonly ReadOnlySpan _buffer; + private int _byteIndex; + private int _bitIndex; + + public QwpBitReader(ReadOnlySpan buffer, int startOffset) + { + if ((uint)startOffset > (uint)buffer.Length) + { + throw new ArgumentOutOfRangeException(nameof(startOffset)); + } + + _buffer = buffer; + _byteIndex = startOffset; + _bitIndex = 0; + } + + public int BytePosition => _bitIndex == 0 ? _byteIndex : _byteIndex + 1; + + /// Reads bits as an unsigned integer, LSB-first. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ulong ReadBits(int bitCount) + { + if ((uint)bitCount > 64) + { + throw new ArgumentOutOfRangeException(nameof(bitCount)); + } + + if (bitCount == 0) return 0UL; + + var endByte = _byteIndex + (_bitIndex + bitCount + 7) / 8; + if (endByte > _buffer.Length) + { + throw new InvalidOperationException("bit reader exhausted"); + } + + ulong value = 0; + var remaining = bitCount; + var collected = 0; + + if (_bitIndex != 0) + { + var availInByte = 8 - _bitIndex; + var take = remaining < availInByte ? remaining : availInByte; + var headMask = (1UL << take) - 1UL; + var chunk = ((ulong)_buffer[_byteIndex] >> _bitIndex) & headMask; + value |= chunk << collected; + collected += take; + remaining -= take; + _bitIndex += take; + if (_bitIndex == 8) + { + _byteIndex++; + _bitIndex = 0; + } + } + + while (remaining >= 8) + { + value |= (ulong)_buffer[_byteIndex] << collected; + _byteIndex++; + collected += 8; + remaining -= 8; + } + + if (remaining > 0) + { + var tailMask = (1UL << remaining) - 1UL; + value |= ((ulong)_buffer[_byteIndex] & tailMask) << collected; + _bitIndex = remaining; + } + + return value; + } +} diff --git a/src/net-questdb-client/Qwp/QwpColumn.cs b/src/net-questdb-client/Qwp/QwpColumn.cs new file mode 100644 index 00000000..7eb890c3 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpColumn.cs @@ -0,0 +1,1168 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// A single columnar accumulator inside a . +/// +/// +/// Storage strategy: separate slices per concern (fixed-width data, bit-packed booleans, +/// string offsets+data, symbol ids, null bitmap). One column type uses one or two of these +/// slots — never all of them. +/// +/// Non-null values are stored densely; null status is tracked in , +/// allocated lazily on the first null. For columns that never see a null, no bitmap is +/// allocated and the on-wire null_flag stays at 0x00. +/// +/// The first non-null append locks the column's type code; subsequent appends of the wrong +/// type throw . +/// +internal sealed class QwpColumn +{ + private const int InitialFixedCapacity = 64; + private const int InitialStringCapacity = 64; + private const int InitialSymbolCapacity = 32; + + private static readonly BigInteger Long256MinSigned = -(BigInteger.One << 255); + private static readonly BigInteger Long256Pow2_256 = BigInteger.One << 256; + + /// + /// Constructs a new column with the given name. Type is set by the first non-null append. + /// + /// The column name as it appears on the wire (UTF-8). Empty string denotes the designated timestamp. + /// + /// Number of leading null rows to backfill — the table's row count at the moment the column + /// was created. Each leading row gets a null bit set. + /// + public QwpColumn(string name, int initialNullRows) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + if (initialNullRows < 0) + { + throw new ArgumentOutOfRangeException(nameof(initialNullRows)); + } + + if (initialNullRows > 0) + { + for (var r = 0; r < initialNullRows; r++) + { + MarkBit(r); + } + + RowCount = initialNullRows; + NullCount = initialNullRows; + } + } + + /// Column name as it appears on the wire. + public string Name { get; } + + /// Column type code, set on the first non-null append. + public QwpTypeCode TypeCode { get; private set; } + + /// Whether has been set. + public bool IsTyped { get; private set; } + + /// Total rows tracked by this column, including nulls. + public int RowCount { get; private set; } + + /// Number of nulls observed so far. + public int NullCount { get; private set; } + + /// Non-null value count (RowCount - NullCount). + public int NonNullCount => RowCount - NullCount; + + /// + /// Per-row null bitmap (1 = null), LSB-first within each byte. Allocated lazily on the first null. + /// Length = ceil(RowCount/8). + /// + /// Public field so can take it by ref. + public byte[]? NullBitmap; + + /// + /// Raw bytes for fixed-width types (BYTE / SHORT / INT / LONG / FLOAT / DOUBLE / DATE / + /// TIMESTAMP / TIMESTAMP_NANOS / UUID / CHAR). Length is bounded by . + /// + public byte[]? FixedData; + + /// Number of valid bytes in . + public int FixedLen; + + /// Bit-packed booleans, LSB-first within each byte. Length = ceil(NonNullCount/8). + public byte[]? BoolData; + + /// VARCHAR offset array; length = NonNullCount + 1 once at least one value present. + public uint[]? StrOffsets; + + /// Concatenated VARCHAR UTF-8 string data, length = last offset. + public byte[]? StrData; + + /// Number of bytes used in . + public int StrLen; + + /// SYMBOL global ids in append order; varint-encoded at frame time. + public int[]? SymbolIds; + + /// DECIMAL scale; emitted as a 1-byte prefix before the values on the wire. Locked on first non-null. + public byte DecimalScale; + + /// Whether has been set by the first non-null append. + public bool DecimalScaleSet; + + /// GEOHASH precision in bits; varint prefix before values. Locked on first non-null. + public int GeohashPrecisionBits; + + /// Whether has been set by the first non-null append. + public bool GeohashPrecisionSet; + + /// + /// Raw bytes accumulated across this column's backing buffers. A conservative proxy for the + /// column's contribution to the wire payload, used for byte-based auto-flush accounting. + /// + public long BufferedBytes + { + get + { + long bytes = FixedLen + StrLen; + if (StrOffsets is not null) bytes += (long)(NonNullCount + 1) * sizeof(uint); + if (SymbolIds is not null) bytes += (long)NonNullCount * sizeof(int); + if (BoolData is not null) bytes += (NonNullCount + 7) >> 3; + return bytes; + } + } + + /// Appends a null marker for a single row. + public void AppendNull() + { + EnsureBitmapCapacity(RowCount + 1); + MarkBit(RowCount); + RowCount++; + NullCount++; + } + + /// Appends a boolean value. + public void AppendBool(bool value) + { + AssertOrSetType(QwpTypeCode.Boolean); + var bitIndex = NonNullCount; + EnsureBoolCapacity(bitIndex + 1); + var byteIndex = bitIndex >> 3; + var bitInByte = bitIndex & 7; + if (bitInByte == 0) + { + BoolData![byteIndex] = 0; + } + var mask = (byte)(1 << bitInByte); + if (value) + { + BoolData![byteIndex] |= mask; + } + + AdvanceNonNull(); + } + + /// Appends a single signed byte. + public void AppendByte(sbyte value) + { + AssertOrSetType(QwpTypeCode.Byte); + EnsureFixedCapacity(FixedLen + 1); + FixedData![FixedLen++] = (byte)value; + AdvanceNonNull(); + } + + /// Appends a 16-bit signed integer (little-endian). + public void AppendShort(short value) + { + AssertOrSetType(QwpTypeCode.Short); + EnsureFixedCapacity(FixedLen + 2); + BinaryPrimitives.WriteInt16LittleEndian(FixedData.AsSpan(FixedLen, 2), value); + FixedLen += 2; + AdvanceNonNull(); + } + + /// Appends a 32-bit signed integer (little-endian). + public void AppendInt(int value) + { + AssertOrSetType(QwpTypeCode.Int); + EnsureFixedCapacity(FixedLen + 4); + BinaryPrimitives.WriteInt32LittleEndian(FixedData.AsSpan(FixedLen, 4), value); + FixedLen += 4; + AdvanceNonNull(); + } + + /// Appends a 64-bit signed integer (little-endian). + public void AppendLong(long value) + { + AssertOrSetType(QwpTypeCode.Long); + EnsureFixedCapacity(FixedLen + 8); + BinaryPrimitives.WriteInt64LittleEndian(FixedData.AsSpan(FixedLen, 8), value); + FixedLen += 8; + AdvanceNonNull(); + } + + /// Appends an IEEE-754 single-precision float (little-endian). + public void AppendFloat(float value) + { + AssertOrSetType(QwpTypeCode.Float); + EnsureFixedCapacity(FixedLen + 4); + BinaryPrimitives.WriteSingleLittleEndian(FixedData.AsSpan(FixedLen, 4), value); + FixedLen += 4; + AdvanceNonNull(); + } + + /// Appends an IEEE-754 double-precision float (little-endian). + public void AppendDouble(double value) + { + AssertOrSetType(QwpTypeCode.Double); + EnsureFixedCapacity(FixedLen + 8); + BinaryPrimitives.WriteDoubleLittleEndian(FixedData.AsSpan(FixedLen, 8), value); + FixedLen += 8; + AdvanceNonNull(); + } + + /// Appends a TIMESTAMP value (microseconds since epoch). + public void AppendTimestampMicros(long micros) + { + AssertOrSetType(QwpTypeCode.Timestamp); + EnsureFixedCapacity(FixedLen + 8); + BinaryPrimitives.WriteInt64LittleEndian(FixedData.AsSpan(FixedLen, 8), micros); + FixedLen += 8; + AdvanceNonNull(); + } + + /// Appends a TIMESTAMP_NANOS value (nanoseconds since epoch). + public void AppendTimestampNanos(long nanos) + { + AssertOrSetType(QwpTypeCode.TimestampNanos); + EnsureFixedCapacity(FixedLen + 8); + BinaryPrimitives.WriteInt64LittleEndian(FixedData.AsSpan(FixedLen, 8), nanos); + FixedLen += 8; + AdvanceNonNull(); + } + + /// Appends a DATE value (milliseconds since epoch). + public void AppendDateMillis(long millis) + { + AssertOrSetType(QwpTypeCode.Date); + EnsureFixedCapacity(FixedLen + 8); + BinaryPrimitives.WriteInt64LittleEndian(FixedData.AsSpan(FixedLen, 8), millis); + FixedLen += 8; + AdvanceNonNull(); + } + + /// Appends a UUID. Wire layout is low 8 bytes followed by high 8 bytes. + public void AppendUuid(Guid value) + { + AssertOrSetType(QwpTypeCode.Uuid); + EnsureFixedCapacity(FixedLen + 16); + var dest = FixedData.AsSpan(FixedLen, 16); + + // .NET's Guid.TryWriteBytes(span) emits the "Microsoft" mixed-endian form: + // bytes 0..3 = field a (int32) little-endian + // bytes 4..5 = field b (int16) little-endian + // bytes 6..7 = field c (int16) little-endian + // bytes 8..15 = fields d..k (raw) + // RFC 4122 writes a/b/c big-endian and d..k unchanged, giving a 16-byte sequence that + // splits cleanly into a 64-bit "high" and "low" half. QWP stores those two halves + // little-endian, low half first. + Span ms = stackalloc byte[16]; + if (!value.TryWriteBytes(ms)) + { + throw new InvalidOperationException("failed to serialise Guid"); + } + + // Low 64 bits = RFC bytes 8..15 read big-endian. LE encoding of that int64 = reverse those 8 bytes. + for (var i = 0; i < 8; i++) + { + dest[i] = ms[15 - i]; + } + + // High 64 bits = RFC bytes 0..7 read big-endian. The RFC representation of those bytes + // is the Microsoft form's first 8 bytes with each field group byte-reversed: + // rfc[0..3] = ms[3..0], rfc[4..5] = ms[5..4], rfc[6..7] = ms[7..6] + // LE encoding of the resulting int64 = reverse rfc[0..7] = ms[6], ms[7], ms[4], ms[5], ms[0..3]. + dest[8] = ms[6]; + dest[9] = ms[7]; + dest[10] = ms[4]; + dest[11] = ms[5]; + dest[12] = ms[0]; + dest[13] = ms[1]; + dest[14] = ms[2]; + dest[15] = ms[3]; + + FixedLen += 16; + AdvanceNonNull(); + } + + /// Appends a CHAR (single UTF-16 code unit) as 2 bytes little-endian. + public void AppendChar(char value) + { + AssertOrSetType(QwpTypeCode.Char); + EnsureFixedCapacity(FixedLen + 2); + BinaryPrimitives.WriteUInt16LittleEndian(FixedData.AsSpan(FixedLen, 2), value); + FixedLen += 2; + AdvanceNonNull(); + } + + /// Appends a VARCHAR value, UTF-8 encoded. + public void AppendVarchar(ReadOnlySpan value) + { + AssertOrSetType(QwpTypeCode.Varchar); + + // Lazily allocate offsets; the first slot is the leading 0 offset. + if (StrOffsets is null) + { + StrOffsets = new uint[InitialSymbolCapacity]; + StrOffsets[0] = 0; + } + + // Reserve worst-case UTF-8 footprint so we encode in one pass; trim by actual length below. + var maxBytes = QwpConstants.StrictUtf8.GetMaxByteCount(value.Length); + EnsureStringCapacity(StrLen + maxBytes); + var written = QwpConstants.StrictUtf8.GetBytes(value, StrData.AsSpan(StrLen, maxBytes)); + StrLen += written; + + // Offsets array carries one trailing offset per non-null value, so length = NonNullCount + 1. + // The slot we need to fill is at index NonNullCount + 1 (the new "end" offset). + EnsureOffsetCapacity(NonNullCount + 2); + StrOffsets[NonNullCount + 1] = (uint)StrLen; + + AdvanceNonNull(); + } + + internal readonly struct Savepoint + { + public readonly int RowCount; + public readonly int NullCount; + public readonly int FixedLen; + public readonly int StrLen; + public readonly QwpTypeCode TypeCode; + public readonly byte DecimalScale; + public readonly int GeohashPrecisionBits; + public readonly bool IsTyped; + public readonly bool DecimalScaleSet; + public readonly bool GeohashPrecisionSet; + + public Savepoint(QwpColumn col) + { + RowCount = col.RowCount; + NullCount = col.NullCount; + FixedLen = col.FixedLen; + StrLen = col.StrLen; + TypeCode = col.TypeCode; + DecimalScale = col.DecimalScale; + GeohashPrecisionBits = col.GeohashPrecisionBits; + IsTyped = col.IsTyped; + DecimalScaleSet = col.DecimalScaleSet; + GeohashPrecisionSet = col.GeohashPrecisionSet; + } + } + + internal Savepoint Snapshot() => new Savepoint(this); + + internal void Restore(Savepoint sp) + { + var oldRowCount = RowCount; + RowCount = sp.RowCount; + NullCount = sp.NullCount; + FixedLen = sp.FixedLen; + StrLen = sp.StrLen; + TypeCode = sp.TypeCode; + DecimalScale = sp.DecimalScale; + GeohashPrecisionBits = sp.GeohashPrecisionBits; + IsTyped = sp.IsTyped; + DecimalScaleSet = sp.DecimalScaleSet; + GeohashPrecisionSet = sp.GeohashPrecisionSet; + + // MarkBit ORs null bits in by row index; clear any set by the rolled-back row so a later + // real value at the same dense index doesn't encode a spurious null. + if (NullBitmap is { } nullBitmap) + { + for (var r = RowCount; r < oldRowCount; r++) + { + nullBitmap[r >> 3] &= (byte)~(1 << (r & 7)); + } + } + + // AppendBool ORs without clearing on non-aligned slots; mask off post-rollback tail. + if (BoolData is { Length: > 0 } boolData) + { + var nonNull = RowCount - NullCount; + var keepBits = nonNull & 7; + var byteIndex = nonNull >> 3; + if (keepBits != 0 && byteIndex < boolData.Length) + { + boolData[byteIndex] &= (byte)((1 << keepBits) - 1); + } + } + } + + /// + /// Drops all row data while preserving the column's name, type, and backing-buffer + /// allocations. Intended for reuse across batches by the sender — the schema definition + /// stays valid, only the contents are recycled. + /// + public void TrimToCurrent() + { + if (FixedData is { Length: > 0 } fixedData && fixedData.Length > FixedLen) + { + Array.Resize(ref FixedData, FixedLen); + } + if (StrData is { Length: > 0 } strData && strData.Length > StrLen) + { + Array.Resize(ref StrData, StrLen); + } + if (StrOffsets is { Length: > 0 } strOffs) + { + var needed = NonNullCount + 1; + if (strOffs.Length > needed) + { + Array.Resize(ref StrOffsets, needed); + } + } + if (SymbolIds is { Length: > 0 } symbolIds && symbolIds.Length > NonNullCount) + { + Array.Resize(ref SymbolIds, NonNullCount); + } + if (BoolData is { Length: > 0 } boolData) + { + var needed = (RowCount + 7) >> 3; + if (boolData.Length > needed) + { + Array.Resize(ref BoolData, needed); + } + } + if (NullBitmap is { Length: > 0 } nb) + { + var needed = (RowCount + 7) >> 3; + if (nb.Length > needed) + { + Array.Resize(ref NullBitmap, needed); + } + } + } + + public void Clear() + { + RowCount = 0; + NullCount = 0; + FixedLen = 0; + StrLen = 0; + if (NullBitmap is not null) + { + Array.Clear(NullBitmap, 0, NullBitmap.Length); + } + // Type / scale / precision pinned for column lifetime so server-side schema doesn't drift. + } + + /// Appends a SYMBOL value as a global dictionary id. Dictionary lookup is the caller's responsibility. + public void AppendSymbol(int globalId) + { + AssertOrSetType(QwpTypeCode.Symbol); + if (globalId < 0) + { + throw new ArgumentOutOfRangeException(nameof(globalId), "symbol id must be non-negative"); + } + + if (SymbolIds is null || NonNullCount == SymbolIds.Length) + { + GrowSymbolArray(); + } + + SymbolIds![NonNullCount] = globalId; + AdvanceNonNull(); + } + + /// Appends a DECIMAL64 value. The first non-null call locks the column's scale. + public void AppendDecimal64(decimal value) + { + AppendDecimalAtSize(QwpTypeCode.Decimal64, value, QwpConstants.Decimal64SizeBytes); + } + + /// + /// Appends a DECIMAL128 value. The first non-null call locks the column's scale; subsequent + /// values must use the same scale or this method throws. + /// + public void AppendDecimal128(decimal value) + { + AppendDecimalAtSize(QwpTypeCode.Decimal128, value, QwpConstants.Decimal128SizeBytes); + } + + /// Appends a DECIMAL256 value. The first non-null call locks the column's scale. + public void AppendDecimal256(decimal value) + { + AppendDecimalAtSize(QwpTypeCode.Decimal256, value, QwpConstants.Decimal256SizeBytes); + } + + public void AppendDecimal64(long unscaledValue, byte scale) + { + ValidateDecimalScale(scale, QwpConstants.MaxDecimal64Scale, "Decimal64"); + AppendDecimalFromMantissa( + QwpTypeCode.Decimal64, + new BigInteger(unscaledValue), + scale, + QwpConstants.Decimal64SizeBytes); + } + + public void AppendDecimal128(long lo, long hi, byte scale) + { + ValidateDecimalScale(scale, QwpConstants.MaxDecimal128Scale, "Decimal128"); + var mantissa = (new BigInteger(hi) << 64) + new BigInteger((ulong)lo); + AppendDecimalFromMantissa( + QwpTypeCode.Decimal128, + mantissa, + scale, + QwpConstants.Decimal128SizeBytes); + } + + public void AppendDecimal256(long l0, long l1, long l2, long l3, byte scale) + { + ValidateDecimalScale(scale, QwpConstants.MaxDecimal256Scale, "Decimal256"); + var mantissa = (new BigInteger(l3) << 192) + + (new BigInteger((ulong)l2) << 128) + + (new BigInteger((ulong)l1) << 64) + + new BigInteger((ulong)l0); + AppendDecimalFromMantissa( + QwpTypeCode.Decimal256, + mantissa, + scale, + QwpConstants.Decimal256SizeBytes); + } + + private static void ValidateDecimalScale(byte scale, int maxScale, string typeName) + { + if (scale > maxScale) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"{typeName} scale {scale} exceeds maximum {maxScale}"); + } + } + + private void AppendDecimalAtSize(QwpTypeCode code, decimal value, int sizeBytes) + { + Span bits = stackalloc int[4]; + decimal.GetBits(value, bits); + var flags = bits[3]; + var negative = (flags & unchecked((int)0x80000000)) != 0; + var scale = (byte)((flags >> 16) & 0x7F); + + var (maxScale, typeName) = code switch + { + QwpTypeCode.Decimal64 => (QwpConstants.MaxDecimal64Scale, "Decimal64"), + QwpTypeCode.Decimal128 => (QwpConstants.MaxDecimal128Scale, "Decimal128"), + QwpTypeCode.Decimal256 => (QwpConstants.MaxDecimal256Scale, "Decimal256"), + _ => (int.MaxValue, code.ToString()), + }; + ValidateDecimalScale(scale, maxScale, typeName); + + // Fast path: no rescale needed — either this is the first non-null write (which locks the + // column scale to this value's scale) or the value's scale already matches the locked scale. + // Serialise the 96-bit mantissa straight into FixedData as fixed-width two's-complement. + // BigInteger is a struct, but the slow path still allocates on the heap per value: its uint[] + // backing store (whenever the magnitude is past int range) plus the byte[] that ToByteArray + // returns. A scale mismatch falls back to that BigInteger rescale arithmetic below. + if (!DecimalScaleSet || scale == DecimalScale) + { + AssertOrSetType(code); + if (!DecimalScaleSet) + { + DecimalScale = scale; + DecimalScaleSet = true; + } + AppendDecimal96TwosComplement((uint)bits[0], (uint)bits[1], (uint)bits[2], negative, sizeBytes); + return; + } + + var mantissa = (new BigInteger((uint)bits[2]) << 64) + | (new BigInteger((uint)bits[1]) << 32) + | new BigInteger((uint)bits[0]); + if (negative) mantissa = -mantissa; + + AppendDecimalFromMantissa(code, mantissa, scale, sizeBytes); + } + + // Writes a 96-bit magnitude (three little-endian 32-bit words) with the given sign into FixedData + // as a fixed-width little-endian two's-complement value. Zero-allocation companion to the + // BigInteger tail of AppendDecimalFromMantissa; produces byte-identical output for the no-rescale + // case (verified by QwpColumnDecimalEncodingTests). + private void AppendDecimal96TwosComplement(uint w0, uint w1, uint w2, bool negative, int sizeBytes) + { + if (!Magnitude96FitsSigned(w0, w1, w2, negative, sizeBytes)) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' decimal at scale {DecimalScale} overflows {sizeBytes * 8}-bit decimal range"); + } + + EnsureFixedCapacity(FixedLen + sizeBytes); + var dest = FixedData.AsSpan(FixedLen, sizeBytes); + dest.Clear(); + // sizeBytes is 8, 16, or 32 — always >= 8, so w0/w1 fit; w2 only when the field is >= 12 bytes + // wide (the 8-byte case guarantees w2 == 0 via the overflow check above). + BinaryPrimitives.WriteUInt32LittleEndian(dest.Slice(0, 4), w0); + BinaryPrimitives.WriteUInt32LittleEndian(dest.Slice(4, 4), w1); + if (sizeBytes >= 12) + { + BinaryPrimitives.WriteUInt32LittleEndian(dest.Slice(8, 4), w2); + } + if (negative) + { + NegateLittleEndian(dest); + } + + FixedLen += sizeBytes; + AdvanceNonNull(); + } + + // True when the unsigned 96-bit magnitude fits the signed range of `sizeBytes` bytes under the + // given sign. A 96-bit magnitude always fits 128-/256-bit; only Decimal64 (8 bytes) can overflow. + private static bool Magnitude96FitsSigned(uint w0, uint w1, uint w2, bool negative, int sizeBytes) + { + if (sizeBytes >= 16) + { + return true; + } + if (w2 != 0) + { + return false; // magnitude >= 2^64, cannot fit signed 64-bit + } + if ((w1 & 0x80000000u) == 0) + { + return true; // |value| < 2^63 fits either sign + } + // Top bit of the 64-bit magnitude is set: only -2^63 (exactly) is representable. + return negative && w1 == 0x80000000u && w0 == 0u; + } + + // In-place little-endian two's-complement negation (invert all bytes, add one). + private static void NegateLittleEndian(Span bytes) + { + var carry = 1; + for (var i = 0; i < bytes.Length; i++) + { + var v = (byte)~bytes[i] + carry; + bytes[i] = (byte)v; + carry = v >> 8; + } + } + + private void AppendDecimalFromMantissa(QwpTypeCode code, BigInteger mantissa, byte sourceScale, int sizeBytes) + { + AssertOrSetType(code); + + byte targetScale; + if (!DecimalScaleSet) + { + targetScale = sourceScale; + DecimalScale = sourceScale; + DecimalScaleSet = true; + } + else + { + targetScale = DecimalScale; + } + + if (sourceScale != targetScale) + { + if (sourceScale < targetScale) + { + mantissa *= BigInteger.Pow(10, targetScale - sourceScale); + } + else + { + var divisor = BigInteger.Pow(10, sourceScale - targetScale); + if (!(mantissa % divisor).IsZero) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' cannot rescale decimal from scale {sourceScale} to {targetScale} without precision loss"); + } + mantissa /= divisor; + } + } + + WriteFixedDecimalMantissa(mantissa, targetScale, sizeBytes); + } + + // Serialises a (possibly rescaled) mantissa into FixedData as fixed-width little-endian + // two's-complement, rejecting only when the magnitude exceeds the field width. + private void WriteFixedDecimalMantissa(BigInteger mantissa, byte targetScale, int sizeBytes) + { + EnsureFixedCapacity(FixedLen + sizeBytes); + var dest = FixedData.AsSpan(FixedLen, sizeBytes); + var bytes = mantissa.ToByteArray(isUnsigned: false, isBigEndian: false); + if (bytes.Length > sizeBytes) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' decimal at scale {targetScale} overflows {sizeBytes * 8}-bit decimal range"); + } + var fill = mantissa.Sign < 0 ? (byte)0xFF : (byte)0x00; + dest.Fill(fill); + bytes.AsSpan().CopyTo(dest); + FixedLen += sizeBytes; + AdvanceNonNull(); + } + + /// Appends a DECIMAL64 value coerced to an explicit scale (see ). + public void AppendDecimal64(decimal value, byte scale) + => AppendDecimalScaled(QwpTypeCode.Decimal64, value, scale, QwpConstants.Decimal64SizeBytes); + + /// Appends a DECIMAL128 value coerced to an explicit scale (see ). + public void AppendDecimal128(decimal value, byte scale) + => AppendDecimalScaled(QwpTypeCode.Decimal128, value, scale, QwpConstants.Decimal128SizeBytes); + + /// Appends a DECIMAL256 value coerced to an explicit scale (see ). + public void AppendDecimal256(decimal value, byte scale) + => AppendDecimalScaled(QwpTypeCode.Decimal256, value, scale, QwpConstants.Decimal256SizeBytes); + + /// + /// Appends a decimal at an explicit, caller-supplied scale. The value is rounded + /// half-away-from-zero to that scale (matching mainstream SQL databases), then stored. The + /// column scale locks to this value on the first non-null write and every later write must use + /// the same scale. The only rejection is magnitude overflow — a value whose integer part does + /// not fit the type's width. + /// + private void AppendDecimalScaled(QwpTypeCode code, decimal value, byte targetScale, int sizeBytes) + { + var (maxScale, typeName) = code switch + { + QwpTypeCode.Decimal64 => (QwpConstants.MaxDecimal64Scale, "Decimal64"), + QwpTypeCode.Decimal128 => (QwpConstants.MaxDecimal128Scale, "Decimal128"), + QwpTypeCode.Decimal256 => (QwpConstants.MaxDecimal256Scale, "Decimal256"), + _ => (int.MaxValue, code.ToString()), + }; + ValidateDecimalScale(targetScale, maxScale, typeName); + + AssertOrSetType(code); + if (!DecimalScaleSet) + { + DecimalScale = targetScale; + DecimalScaleSet = true; + } + else if (DecimalScale != targetScale) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' decimal scale is locked at {DecimalScale}; cannot write at scale {targetScale}"); + } + + // Round to the column scale, half away from zero (the coercion mainstream SQL databases apply). + // Math.Round caps `decimals` at 28; a target scale beyond System.Decimal's 28-digit limit needs + // no rounding (a decimal can't carry that many fractional digits) and is reached purely by the + // lossless scale-up below. + var rounded = Math.Round(value, Math.Min((int)targetScale, 28), MidpointRounding.AwayFromZero); + + Span bits = stackalloc int[4]; + decimal.GetBits(rounded, bits); + var negative = (bits[3] & unchecked((int)0x80000000)) != 0; + var rScale = (byte)((bits[3] >> 16) & 0x7F); + + // After rounding to min(targetScale, 28), rScale <= targetScale — always a lossless scale-up. + // When the scales already match, reuse the zero-allocation fast path. + if (rScale == targetScale) + { + AppendDecimal96TwosComplement((uint)bits[0], (uint)bits[1], (uint)bits[2], negative, sizeBytes); + return; + } + + var mantissa = (new BigInteger((uint)bits[2]) << 64) + | (new BigInteger((uint)bits[1]) << 32) + | new BigInteger((uint)bits[0]); + if (negative) mantissa = -mantissa; + mantissa *= BigInteger.Pow(10, targetScale - rScale); + WriteFixedDecimalMantissa(mantissa, targetScale, sizeBytes); + } + + /// Appends a BINARY value as length-prefixed opaque bytes (same wire layout as VARCHAR). + public void AppendBinary(ReadOnlySpan value) + { + AssertOrSetType(QwpTypeCode.Binary); + + if (StrOffsets is null) + { + StrOffsets = new uint[InitialSymbolCapacity]; + StrOffsets[0] = 0; + } + + EnsureStringCapacity(StrLen + value.Length); + value.CopyTo(StrData.AsSpan(StrLen, value.Length)); + StrLen += value.Length; + + EnsureOffsetCapacity(NonNullCount + 2); + StrOffsets[NonNullCount + 1] = (uint)StrLen; + + AdvanceNonNull(); + } + + /// Appends an IPv4 address as 4 bytes little-endian (same wire layout as INT). + public void AppendIPv4(uint addr) + { + AssertOrSetType(QwpTypeCode.IPv4); + EnsureFixedCapacity(FixedLen + 4); + BinaryPrimitives.WriteUInt32LittleEndian(FixedData.AsSpan(FixedLen, 4), addr); + FixedLen += 4; + AdvanceNonNull(); + } + + /// + /// Appends a LONG256 value as four little-endian 64-bit words. Zero-allocation; matches the + /// canonical wire layout exactly. + /// + public void AppendLong256(long l0, long l1, long l2, long l3) + { + AssertOrSetType(QwpTypeCode.Long256); + EnsureFixedCapacity(FixedLen + QwpConstants.Long256SizeBytes); + var dest = FixedData.AsSpan(FixedLen, QwpConstants.Long256SizeBytes); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(0, 8), l0); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(8, 8), l1); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(16, 8), l2); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(24, 8), l3); + FixedLen += QwpConstants.Long256SizeBytes; + AdvanceNonNull(); + } + + /// + /// Appends a LONG256 value from a . Accepts the full 256-bit + /// range under either interpretation: [0, 2^256 - 1] unsigned or + /// [-(2^255), 2^255 - 1] signed. Negative values are encoded as two's-complement + /// low 32 bytes. + /// + public void AppendLong256(BigInteger value) + { + AssertOrSetType(QwpTypeCode.Long256); + EnsureFixedCapacity(FixedLen + QwpConstants.Long256SizeBytes); + var dest = FixedData.AsSpan(FixedLen, QwpConstants.Long256SizeBytes); + + var raw = value; + if (raw.Sign < 0) + { + if (raw < Long256MinSigned) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' Long256 value below the 256-bit signed minimum -(2^255)"); + } + raw = Long256Pow2_256 + raw; + } + + if (!raw.TryWriteBytes(dest, out var bytesWritten, isUnsigned: true, isBigEndian: false)) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' Long256 value exceeds the 256-bit unsigned maximum 2^256 - 1"); + } + + if (bytesWritten < QwpConstants.Long256SizeBytes) + { + dest.Slice(bytesWritten).Clear(); + } + + FixedLen += QwpConstants.Long256SizeBytes; + AdvanceNonNull(); + } + + /// + /// Appends a GEOHASH value with the given precision. The first non-null call locks the + /// column precision; subsequent values must use the same precision. + /// + /// The geohash bits packed into the low bits. + /// Precision in bits, in the range [1, 60]. + public void AppendGeohash(ulong hash, int precisionBits) + { + AssertOrSetType(QwpTypeCode.Geohash); + + if (precisionBits < QwpConstants.MinGeohashPrecisionBits || precisionBits > QwpConstants.MaxGeohashPrecisionBits) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' geohash precision must be in [{QwpConstants.MinGeohashPrecisionBits}, " + + $"{QwpConstants.MaxGeohashPrecisionBits}] bits, got {precisionBits}"); + } + + if (!GeohashPrecisionSet) + { + GeohashPrecisionBits = precisionBits; + GeohashPrecisionSet = true; + } + else if (GeohashPrecisionBits != precisionBits) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' geohash precision mismatch: previously {GeohashPrecisionBits}, now {precisionBits}"); + } + + if (precisionBits < 64) + { + hash &= (1UL << precisionBits) - 1UL; + } + + var byteCount = (precisionBits + 7) >> 3; + EnsureFixedCapacity(FixedLen + byteCount); + var dest = FixedData.AsSpan(FixedLen, byteCount); + + for (var i = 0; i < byteCount; i++) + { + dest[i] = (byte)(hash >> (i * 8)); + } + + FixedLen += byteCount; + AdvanceNonNull(); + } + + /// + /// Appends a DOUBLE_ARRAY row. Wire layout: uint8 nDims + nDims × int32 LE + /// dimension lengths + product(shape) × float64 LE values. + /// + public void AppendDoubleArray(ReadOnlySpan values, ReadOnlySpan shape) + { + AssertOrSetType(QwpTypeCode.DoubleArray); + AppendArrayCore(MemoryMarshal.AsBytes(values), values.Length, shape, elementSize: 8); + } + + /// + /// Appends a LONG_ARRAY row. Wire layout: uint8 nDims + nDims × int32 LE + /// dimension lengths + product(shape) × int64 LE values. + /// + public void AppendLongArray(ReadOnlySpan values, ReadOnlySpan shape) + { + AssertOrSetType(QwpTypeCode.LongArray); + AppendArrayCore(MemoryMarshal.AsBytes(values), values.Length, shape, elementSize: 8); + } + + private void AppendArrayCore(ReadOnlySpan valueBytes, int valueCount, ReadOnlySpan shape, int elementSize) + { + if (!BitConverter.IsLittleEndian) + { + // .NET runs on x64 and arm64 in practice; both little-endian. A big-endian host would + // need per-element byte swapping, which we don't ship until there's a real demand. + throw new PlatformNotSupportedException("QWP array encoding requires a little-endian host"); + } + + if (shape.Length < 1 || shape.Length > QwpConstants.MaxArrayDimensions) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"array dimensions must be in [1, {QwpConstants.MaxArrayDimensions}], got {shape.Length}"); + } + + long expected = 1; + for (var i = 0; i < shape.Length; i++) + { + if (shape[i] < 0) + { + throw new IngressError(ErrorCode.InvalidApiCall, "array shape dimensions must be non-negative"); + } + + expected *= shape[i]; + } + + if (expected != valueCount) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"array shape product ({expected}) does not match value count ({valueCount})"); + } + + var byteCountLong = 1L + (long)shape.Length * 4L + (long)valueCount * elementSize; + if (byteCountLong > QwpConstants.MaxBatchBytes) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"array payload ({byteCountLong} bytes) exceeds the {QwpConstants.MaxBatchBytes}-byte batch limit"); + } + var byteCount = (int)byteCountLong; + EnsureFixedCapacity(FixedLen + byteCount); + var dest = FixedData.AsSpan(FixedLen, byteCount); + + dest[0] = (byte)shape.Length; + var pos = 1; + for (var i = 0; i < shape.Length; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(dest.Slice(pos, 4), shape[i]); + pos += 4; + } + + valueBytes.CopyTo(dest.Slice(pos)); + + FixedLen += byteCount; + AdvanceNonNull(); + } + + private void AssertOrSetType(QwpTypeCode code) + { + if (!IsTyped) + { + TypeCode = code; + IsTyped = true; + return; + } + + if (TypeCode != code) + { + throw new IngressError( + ErrorCode.InvalidApiCall, + $"column '{Name}' was first written as {TypeCode} but is now being written as {code}"); + } + } + + private void AdvanceNonNull() + { + // Bitmap stays unallocated until the first null arrives, but we must extend its conceptual + // length whenever the row count grows. EnsureBitmapCapacity is a no-op when no bitmap exists. + EnsureBitmapCapacity(RowCount + 1); + // Non-null bit defaults to 0; nothing to write. + RowCount++; + } + + private void EnsureFixedCapacity(int required) + { + if (FixedData is null) + { + FixedData = new byte[Math.Max(InitialFixedCapacity, required)]; + return; + } + + if (FixedData.Length < required) + { + Array.Resize(ref FixedData, GrowTo(FixedData.Length, required, "fixed-width column")); + } + } + + private void EnsureBoolCapacity(int requiredBits) + { + var requiredBytes = (requiredBits + 7) >> 3; + if (BoolData is null) + { + BoolData = new byte[Math.Max(8, requiredBytes)]; + return; + } + + if (BoolData.Length < requiredBytes) + { + Array.Resize(ref BoolData, GrowTo(BoolData.Length, requiredBytes, "boolean column")); + } + } + + private void EnsureStringCapacity(int required) + { + if (StrData is null) + { + StrData = new byte[Math.Max(InitialStringCapacity, required)]; + return; + } + + if (StrData.Length < required) + { + Array.Resize(ref StrData, GrowTo(StrData.Length, required, "string column")); + } + } + + private void EnsureOffsetCapacity(int requiredCount) + { + if (StrOffsets is null) + { + StrOffsets = new uint[Math.Max(InitialSymbolCapacity, requiredCount)]; + StrOffsets[0] = 0; + return; + } + + if (StrOffsets.Length < requiredCount) + { + Array.Resize(ref StrOffsets, GrowTo(StrOffsets.Length, requiredCount, "string-offset array")); + } + } + + private void GrowSymbolArray() + { + if (SymbolIds is null) + { + SymbolIds = new int[InitialSymbolCapacity]; + return; + } + + Array.Resize(ref SymbolIds, GrowTo(SymbolIds.Length, SymbolIds.Length + 1, "symbol-id array")); + } + + private void EnsureBitmapCapacity(int rowCount) + { + if (NullBitmap is null) + { + return; // bitmap is created lazily on first null in MarkBit. + } + + var requiredBytes = (rowCount + 7) >> 3; + if (NullBitmap.Length < requiredBytes) + { + Array.Resize(ref NullBitmap, GrowTo(NullBitmap.Length, requiredBytes, "null bitmap")); + } + } + + private void MarkBit(int rowIndex) + { + if (NullBitmap is null) + { + // First null: allocate bitmap sized for the current row count + this bit. + var bytes = ((rowIndex + 1) + 7) >> 3; + NullBitmap = new byte[Math.Max(8, bytes)]; + } + else + { + var requiredBytes = ((rowIndex + 1) + 7) >> 3; + if (NullBitmap.Length < requiredBytes) + { + Array.Resize(ref NullBitmap, GrowTo(NullBitmap.Length, requiredBytes, "null bitmap")); + } + } + + NullBitmap[rowIndex >> 3] |= (byte)(1 << (rowIndex & 7)); + } + + // Doubles currentLength until it covers required, accumulating in long so the doubling + // can't overflow negative; clamps at int.MaxValue and rejects a genuinely oversized column. + private static int GrowTo(int currentLength, int required, string what) + { + if (required < 0) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"QWP {what} capacity requirement overflowed int.MaxValue"); + } + + var newSize = Math.Max(1L, (long)currentLength); + while (newSize < required) + { + newSize *= 2; + if (newSize > int.MaxValue) + { + newSize = int.MaxValue; + break; + } + } + + if (newSize < required) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"QWP {what} required size {required} exceeds the {int.MaxValue}-byte cap"); + } + + return (int)newSize; + } +} diff --git a/src/net-questdb-client/Qwp/QwpConnectStringKeys.cs b/src/net-questdb-client/Qwp/QwpConnectStringKeys.cs new file mode 100644 index 00000000..b865491d --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpConnectStringKeys.cs @@ -0,0 +1,80 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp; + +/// +/// QWP connect-string key vocabulary, split by side. A ws:: / wss:: connect +/// string is a single user input shared between the ingress sender and the egress query +/// client; each parser accepts the full union and silently ignores the keys belonging to +/// the other side, so one connect string drives both clients without erroring. +/// +internal static class QwpConnectStringKeys +{ + /// + /// Keys both the ingress sender and the egress query client recognise (auth, TLS, + /// addressing and other transport-level knobs). Each side honours the ones it acts on + /// and ignores the rest. protocol is the synthetic scheme key the ingress parser + /// stores in its builder; it is not a user-facing QWP option but must be admitted so the + /// ingress known-key check passes. + /// + internal static readonly string[] Shared = + { + "addr", "protocol", "tls_verify", "tls_roots", "tls_roots_password", + "username", "user", "password", "pass", "token", + "auth_timeout_ms", + "zone", "error_inbox_capacity", + }; + + /// + /// Ingress-sender-only QWP keys. The egress query client accepts and silently ignores + /// these. ping_timeout and proxy are .NET-specific QWP ingress knobs the + /// WebSocket sender acts on; the rest mirror the cross-client connect-string vocabulary. + /// + internal static readonly string[] IngressOnly = + { + "auto_flush", "auto_flush_rows", "auto_flush_bytes", "auto_flush_interval", + "init_buf_size", "max_buf_size", "max_name_len", "convert_local_to_utc", + "request_durable_ack", + "sf_dir", "sender_id", "sf_max_bytes", "sf_max_total_bytes", "sf_durability", + "sf_append_deadline_millis", + "reconnect_max_duration_millis", "reconnect_initial_backoff_millis", "reconnect_max_backoff_millis", + "initial_connect_retry", "close_flush_timeout_millis", + "drain_orphans", "max_background_drainers", "ping_timeout", + "durable_ack_keepalive_interval_millis", "proxy", "transaction", + "connection_listener_inbox_capacity", + "on_server_error", "on_schema_error", "on_schema_mismatch_error", "on_parse_error", + "on_internal_error", "on_security_error", "on_write_error", + }; + + /// Egress-query-client-only keys. The ingress sender accepts and silently ignores these. + internal static readonly string[] EgressOnly = + { + "path", "compression", "compression_level", + "failover", "failover_max_attempts", "failover_backoff_initial_ms", + "failover_backoff_max_ms", "failover_max_duration_ms", + "max_batch_rows", "client_id", "buffer_pool_size", + "target", "initial_credit", + }; +} diff --git a/src/net-questdb-client/Qwp/QwpConnectionEventDispatcher.cs b/src/net-questdb-client/Qwp/QwpConnectionEventDispatcher.cs new file mode 100644 index 00000000..87679a77 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpConnectionEventDispatcher.cs @@ -0,0 +1,119 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Diagnostics; +using System.Threading.Channels; +using QuestDB.Senders; + +namespace QuestDB.Qwp; + +internal sealed class QwpConnectionEventDispatcher : IDisposable +{ + private readonly Channel _inbox; + private readonly ISenderConnectionListener _listener; + private readonly CancellationTokenSource _shutdown = new(); + private readonly object _lifecycleLock = new(); + private long _dropped; + private long _delivered; + private Task? _loop; + private int _started; + private int _disposed; + + public QwpConnectionEventDispatcher(ISenderConnectionListener listener, int capacity) + { + ArgumentNullException.ThrowIfNull(listener); + if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity)); + _listener = listener; + _inbox = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.DropWrite, + SingleReader = true, + SingleWriter = false, + }); + } + + public long DroppedNotifications => Volatile.Read(ref _dropped); + public long TotalDelivered => Volatile.Read(ref _delivered); + + public bool Offer(SenderConnectionEvent evt) + { + if (Volatile.Read(ref _disposed) != 0) return false; + if (!_inbox.Writer.TryWrite(evt)) + { + Interlocked.Increment(ref _dropped); + return false; + } + if (Interlocked.CompareExchange(ref _started, 1, 0) == 0) + { + // _lifecycleLock pairs with Dispose's _loop read: Dispose joins this loop, or this + // branch sees the disposal first and never starts on an already-disposed CTS. + lock (_lifecycleLock) + { + if (Volatile.Read(ref _disposed) == 0) + { + _loop = Task.Run(DispatchLoopAsync); + } + else + { + Interlocked.Increment(ref _dropped); + } + } + } + return true; + } + + private async Task DispatchLoopAsync() + { + try + { + while (await _inbox.Reader.WaitToReadAsync(_shutdown.Token).ConfigureAwait(false)) + { + while (_inbox.Reader.TryRead(out var evt)) + { + Interlocked.Increment(ref _delivered); + try { _listener.OnEvent(evt); } + catch (Exception t) { Trace.TraceError($"ISenderConnectionListener.OnEvent threw: {t}"); } + } + } + } + catch (OperationCanceledException) { } + // A racing Dispose can dispose _shutdown while WaitToReadAsync observes its token; treat + // the resulting ObjectDisposedException as a clean shutdown rather than a faulted task. + catch (ObjectDisposedException) { } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _inbox.Writer.TryComplete(); + try { _shutdown.Cancel(); } catch { } + Task? loop; + lock (_lifecycleLock) + { + loop = _loop; + } + try { loop?.Wait(TimeSpan.FromSeconds(2)); } catch { } + _shutdown.Dispose(); + } +} diff --git a/src/net-questdb-client/Qwp/QwpConstants.cs b/src/net-questdb-client/Qwp/QwpConstants.cs new file mode 100644 index 00000000..7a5dbad0 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpConstants.cs @@ -0,0 +1,237 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Text; + +namespace QuestDB.Qwp; + +/// +/// Wire-format constants and limits for the QWP v1 protocol. +/// +internal static class QwpConstants +{ + /// Strict UTF-8 (throws on invalid bytes / lone surrogates) for all wire identifiers. + public static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + /// The 4-byte magic that opens every QWP v1 frame: ASCII "QWP1" stored little-endian. + public const uint Magic = 0x31_50_57_51u; + + /// Total size of the fixed message header in bytes. + public const int HeaderSize = 12; + + /// Byte offset of the magic value within the header. + public const int OffsetMagic = 0; + + /// Byte offset of the version byte within the header. + public const int OffsetVersion = 4; + + /// Byte offset of the flags byte within the header. + public const int OffsetFlags = 5; + + /// Byte offset of the 16-bit table count within the header. + public const int OffsetTableCount = 6; + + /// Byte offset of the 32-bit payload length within the header. + public const int OffsetPayloadLength = 8; + + /// The single QWP protocol version this client speaks. Ingress and egress share it. + public const byte SupportedVersion = 0x01; + + /// + /// Defer the WAL commit: the server appends the frame's rows to the WAL writers but skips the + /// commit until a subsequent frame arrives without this flag. Set on auto-flush frames in + /// transactional mode so a dataset larger than the server recv buffer can be staged and then + /// committed atomically per table by an explicit (non-deferred) flush. Matches the Java + /// client's FLAG_DEFER_COMMIT. + /// + public const byte FlagDeferCommit = 0x01; + + /// Timestamp columns may use Gorilla delta-of-delta encoding. + public const byte FlagGorilla = 0x04; + + /// + /// Symbol columns use the connection-global delta dictionary instead of per-table dictionaries. + /// The WebSocket sender always sets this flag. + /// + public const byte FlagDeltaSymbolDict = 0x08; + + /// Byte offset of the tableCount field in an OK response (after status + sequence). + public const int OffsetTableCountInOkAck = 9; + + /// Size of an error response header: 1-byte status + 8-byte sequence + 2-byte message length. + public const int ErrorAckHeaderSize = 11; + + /// Hard cap on the length of a server-supplied error message. + public const int MaxErrorMessageBytes = 1024; + + /// Maximum size of a single batch payload, in bytes. + public const int MaxBatchBytes = 16 * 1024 * 1024; + + /// Hard ceiling on the number of tables in one frame; the wire field is a uint16. + public const int MaxTablesPerMessage = 0xFFFF; + + /// Maximum number of rows in a single table block. + public const int MaxRowsPerTable = 1_000_000; + + /// Maximum number of columns in a single table. + public const int MaxColumnsPerTable = 2048; + + /// Maximum table or column name length, in UTF-8 bytes. + public const int MaxNameLengthBytes = 127; + + /// Maximum number of dimensions for ARRAY columns (matches existing client convention). + public const int MaxArrayDimensions = 32; + + /// Inclusive lower bound on geohash precision (in bits). + public const int MinGeohashPrecisionBits = 1; + + /// Inclusive upper bound on geohash precision (in bits). + public const int MaxGeohashPrecisionBits = 60; + + /// LONG256 wire size, in bytes. + public const int Long256SizeBytes = 32; + + /// DECIMAL64 unscaled value size on the wire, in bytes. + public const int Decimal64SizeBytes = 8; + + /// Inclusive upper bound on Decimal64 scale (digits after the decimal point). + public const int MaxDecimal64Scale = 18; + + /// Inclusive upper bound on Decimal128 scale (digits after the decimal point). + public const int MaxDecimal128Scale = 38; + + /// Inclusive upper bound on Decimal256 scale (digits after the decimal point). + public const int MaxDecimal256Scale = 76; + + /// DECIMAL128 unscaled value size on the wire, in bytes. + public const int Decimal128SizeBytes = 16; + + /// DECIMAL256 unscaled value size on the wire, in bytes. + public const int Decimal256SizeBytes = 32; + + /// Default port for ws:: and wss::; shared with HTTP. + public const int DefaultPort = 9000; + + /// Hard-coded WebSocket endpoint path for QWP ingest. + public const string WritePath = "/write/v4"; + + /// Hard-coded WebSocket endpoint path for QWP egress (query). + public const string ReadPath = "/read/v1"; + + /// RESULT_BATCH payload is zstd-compressed after the prelude. + public const byte FlagZstd = 0x10; + + /// QWP egress message kinds (the first byte of every egress payload). + public const byte MsgKindQueryRequest = 0x10; + public const byte MsgKindResultBatch = 0x11; + public const byte MsgKindResultEnd = 0x12; + public const byte MsgKindQueryError = 0x13; + public const byte MsgKindCancel = 0x14; + public const byte MsgKindCredit = 0x15; + public const byte MsgKindExecDone = 0x16; + public const byte MsgKindCacheReset = 0x17; + public const byte MsgKindServerInfo = 0x18; + + /// CACHE_RESET reset_mask bits. Bit 0 = SYMBOL dict is the only bit defined. + public const byte ResetMaskDict = 0x01; + + /// SERVER_INFO capabilities bits. CapZone = server appends zone_id_len+zone_id after node_id. + public const uint CapZone = 0x00000001; + + /// QWP egress status codes (in QUERY_ERROR frames). No STATUS_OK — egress success is RESULT_END. + public const byte StatusSchemaMismatch = 0x03; + public const byte StatusParseError = 0x05; + public const byte StatusInternalError = 0x06; + public const byte StatusSecurityError = 0x08; + public const byte StatusCancelled = 0x0A; + public const byte StatusLimitExceeded = 0x0B; + + /// Connection-level errors (parse failure on the message frame, auth failure) carry this id. + public const long RequestIdWildcard = -1L; + + /// Egress wire limits. + public const int MaxSqlLengthBytes = 1024 * 1024; + + public const int MaxBindParameters = 1024; + + public const int MaxResultBatchWireBytes = 16 * 1024 * 1024; + + /// Server soft caps; clients must accept any policy and tolerate CACHE_RESET at any query boundary. + public const int SymbolDictEntriesSoftCap = 100_000; + public const int SymbolDictHeapBytesSoftCap = 8 * 1024 * 1024; + + /// Client-side hard caps to bound resource use against a hostile or buggy server. + public const int MaxConnSymbolDictEntries = 8 * 1024 * 1024; + public const int MaxConnSymbolDictHeapBytes = 256 * 1024 * 1024; + + /// Inclusive zstd compression level range. Server clamps anything higher to 9 silently. + public const int ZstdLevelMin = 1; + public const int ZstdLevelMax = 9; + + /// Egress upgrade headers. + public const string HeaderAcceptEncoding = "X-QWP-Accept-Encoding"; + public const string HeaderContentEncoding = "X-QWP-Content-Encoding"; + public const string HeaderMaxBatchRows = "X-QWP-Max-Batch-Rows"; + + /// Server → client: hard cap on ingest QWP message payload bytes. + public const string HeaderMaxBatchSize = "X-QWP-Max-Batch-Size"; + + /// Default auto-flush threshold by row count. + public const int DefaultAutoFlushRows = 1000; + + /// Default auto-flush interval, in milliseconds. + public const int DefaultAutoFlushIntervalMs = 100; + + /// Default close-drain timeout, in milliseconds. + public const int DefaultCloseTimeoutMs = 5000; + + /// Default ACK timeout for in-flight batches, in milliseconds. + public const int DefaultAckTimeoutMs = 30_000; + + /// Client → server: maximum QWP version the client supports. + public const string HeaderMaxVersion = "X-QWP-Max-Version"; + + /// Client → server: free-form client identifier. + public const string HeaderClientId = "X-QWP-Client-Id"; + + /// Server → client: negotiated QWP version. + public const string HeaderVersion = "X-QWP-Version"; + + /// Server → client: replication role on 101 (diagnostic) and 421 (role-reject) responses. + public const string HeaderQuestDbRole = "X-QuestDB-Role"; + + /// Server → client: zone identifier on 421 (role-reject) responses; companion to SERVER_INFO.zone_id. + public const string HeaderQuestDbZone = "X-QuestDB-Zone"; + + public const string RoleStandaloneName = "STANDALONE"; + public const string RolePrimaryName = "PRIMARY"; + public const string RoleReplicaName = "REPLICA"; + public const string RolePrimaryCatchupName = "PRIMARY_CATCHUP"; + + /// Client → server: opt-in for STATUS_DURABLE_ACK frames. + public const string HeaderRequestDurableAck = "X-QWP-Request-Durable-Ack"; + + /// Server → client: echoed when the server will deliver durable-ack frames. + public const string HeaderDurableAck = "X-QWP-Durable-Ack"; +} diff --git a/src/net-questdb-client/Qwp/QwpEncoder.cs b/src/net-questdb-client/Qwp/QwpEncoder.cs new file mode 100644 index 00000000..821f0b6f --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpEncoder.cs @@ -0,0 +1,531 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Serialises one or more s into a single QWP v1 frame. +/// +/// +/// Single multi-table message per flush: one frame holds every non-empty table, with a +/// shared delta-symbol-dictionary prelude. The wire tableCount field is a uint16, +/// capped at . +/// +/// Inline schemas only. Each table block carries its column definitions inline; the wire +/// no longer has a schema-mode byte or schema-id. The encoder is stateless across flushes. +/// +/// FLAG_DELTA_SYMBOL_DICT is always set; symbol columns reference connection-global ids. +/// In self-sufficient mode the prelude carries the dictionary prefix from id 0 covering every +/// id the frame references; in delta mode it carries only the delta since the last flush. +/// When a frame references no symbols, the prelude is empty (0x00 0x00) but still written. +/// +/// FLAG_GORILLA is always set. Each TIMESTAMP / TIMESTAMP_NANOS column body is +/// prefixed with an encoding_flag byte (0x00 uncompressed, 0x01 Gorilla +/// DoD); the encoder transparently falls back to uncompressed when DoDs overflow int32, and +/// always emits the flag (even for all-null columns). +/// +/// The encoder reads the symbol dictionary but never mutates it; in self-sufficient mode it +/// re-emits the dictionary prefix from id 0 on every frame, so there is no per-flush watermark +/// to advance. +/// +internal static class QwpEncoder +{ + private const int InitialCapacity = 4096; + + /// + /// Encodes the supplied tables into a single QWP frame. + /// + /// Non-empty tables to include. The caller is expected to filter out tables with zero rows. + /// Connection-global symbol dictionary; only the delta is emitted. + /// + /// If true, the symbol delta prelude starts at id 0 — the receiver needs no prior + /// connection state. Required by store-and-forward mode where each frame must be replayable + /// in isolation. Column schemas always travel inline regardless of this flag. Defaults to false. + /// + /// The complete QWP frame, including the 12-byte header. + /// Allocates per call. Production paths use directly. + internal static byte[] Encode( + IReadOnlyList tables, + QwpSymbolDictionary symbolDictionary, + bool selfSufficient = false, + bool deferCommit = false) + { + var builder = new FrameBuilder(InitialCapacity); + var len = EncodeInto(builder, tables, symbolDictionary, selfSufficient, deferCommit: deferCommit); + var result = new byte[len]; + builder.AsSpan(0, len).CopyTo(result); + return result; + } + + /// + /// Encodes a frame into the provided , reusing the builder's + /// internal buffer across calls. Used by the double-buffered async path to avoid a + /// per-flush allocation. + /// + /// Number of bytes written into the builder; the caller reads builder.AsSpan(0, length). + internal static int EncodeInto( + FrameBuilder builder, + IReadOnlyList tables, + QwpSymbolDictionary symbolDictionary, + bool selfSufficient = false, + int symbolDeltaCount = -1, + bool deferCommit = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(tables); + ArgumentNullException.ThrowIfNull(symbolDictionary); + + if (tables.Count > QwpConstants.MaxTablesPerMessage) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"too many tables ({tables.Count}); the wire ceiling is {QwpConstants.MaxTablesPerMessage}"); + } + + builder.Reset(); + + // Reserve the 12-byte header; we patch it at the end once the payload length is known. + builder.Allocate(QwpConstants.HeaderSize); + + WriteDeltaSymbolDict(builder, symbolDictionary, selfSufficient, symbolDeltaCount); + + var emittedTableCount = 0; + for (var i = 0; i < tables.Count; i++) + { + var t = tables[i]; + if (t.RowCount == 0 || t.TotalColumnCount == 0) + { + continue; + } + + WriteTableBlock(builder, t); + emittedTableCount++; + } + + var payloadLength = builder.Length - QwpConstants.HeaderSize; + if (payloadLength > QwpConstants.MaxBatchBytes) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"payload size {payloadLength} bytes exceeds the {QwpConstants.MaxBatchBytes}-byte limit; flush more often"); + } + + // Patch header. + var header = builder.AsSpan(0, QwpConstants.HeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(header.Slice(QwpConstants.OffsetMagic, 4), QwpConstants.Magic); + header[QwpConstants.OffsetVersion] = QwpConstants.SupportedVersion; + header[QwpConstants.OffsetFlags] = (byte)(QwpConstants.FlagDeltaSymbolDict | QwpConstants.FlagGorilla + | (deferCommit ? QwpConstants.FlagDeferCommit : 0)); + BinaryPrimitives.WriteUInt16LittleEndian(header.Slice(QwpConstants.OffsetTableCount, 2), (ushort)emittedTableCount); + BinaryPrimitives.WriteUInt32LittleEndian(header.Slice(QwpConstants.OffsetPayloadLength, 4), (uint)payloadLength); + + return builder.Length; + } + + private static void WriteDeltaSymbolDict(FrameBuilder buf, QwpSymbolDictionary symbols, bool selfSufficient, int symbolDeltaCount) + { + int deltaStart, deltaCount; + if (selfSufficient) + { + // Re-emit the dictionary prefix [0, symbolDeltaCount) every frame; + // symbolDeltaCount < 0 means the whole dictionary. + deltaStart = 0; + deltaCount = symbolDeltaCount >= 0 ? symbolDeltaCount : symbols.Count; + } + else + { + deltaStart = symbols.DeltaStart; + deltaCount = symbols.DeltaCount; + } + + if (deltaStart + deltaCount > symbols.Count) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"symbol delta range [{deltaStart}, {deltaStart + deltaCount}) exceeds dictionary size {symbols.Count}"); + } + + buf.WriteVarint((ulong)deltaStart); + buf.WriteVarint((ulong)deltaCount); + + for (var i = deltaStart; i < deltaStart + deltaCount; i++) + { + var sym = symbols.GetSymbol(i); + WriteString(buf, sym); + } + } + + private static void WriteTableBlock(FrameBuilder buf, QwpTableBuffer table) + { + WriteString(buf, table.TableName); + buf.WriteVarint((ulong)table.RowCount); + buf.WriteVarint((ulong)table.TotalColumnCount); + + // Column definitions are always inline. User columns first, designated timestamp last. + for (var i = 0; i < table.Columns.Count; i++) + { + WriteColumnDef(buf, table.Columns[i]); + } + + if (table.DesignatedTimestampColumn is not null) + { + WriteColumnDef(buf, table.DesignatedTimestampColumn); + } + + // Column data sections (in the same order as definitions: user columns first, designated TS last). + for (var i = 0; i < table.Columns.Count; i++) + { + WriteColumnData(buf, table.Columns[i], table.RowCount); + } + + if (table.DesignatedTimestampColumn is not null) + { + WriteColumnData(buf, table.DesignatedTimestampColumn, table.RowCount); + } + } + + private static void WriteColumnDef(FrameBuilder buf, QwpColumn col) + { + if (!col.IsTyped) + { + throw new IngressError(ErrorCode.InvalidApiCall, $"column '{col.Name}' has no type assigned"); + } + + WriteString(buf, col.Name); + buf.WriteByte((byte)col.TypeCode); + } + + private static void WriteColumnData(FrameBuilder buf, QwpColumn col, int rowCount) + { + // An untyped column has no per-type metadata to emit; a short body would desync the wire. + if (!col.IsTyped) + { + throw new IngressError(ErrorCode.InvalidApiCall, $"column '{col.Name}' has no type assigned"); + } + + // Null flag + optional bitmap + if (col.NullCount == 0) + { + buf.WriteByte(0); + } + else + { + buf.WriteByte(1); + var bitmapBytes = (rowCount + 7) >> 3; + buf.WriteBytes(col.NullBitmap!.AsSpan(0, bitmapBytes)); + } + + var n = col.NonNullCount; + + // FLAG_GORILLA promises a per-column encoding-flag byte; emit it even when all values are null. + if (col.TypeCode is QwpTypeCode.Timestamp or QwpTypeCode.TimestampNanos && n == 0) + { + buf.WriteByte(QwpGorilla.EncodingUncompressed); + return; + } + + // Several types carry per-column metadata (offset table for VARCHAR/BINARY, scale byte for + // DECIMAL*, precision varint for GEOHASH) that the wire format requires regardless of + // value count. Skip the early-return for those. + if (n == 0) + { + switch (col.TypeCode) + { + case QwpTypeCode.Varchar: + case QwpTypeCode.Binary: + buf.WriteUInt32LittleEndian(0); + return; + case QwpTypeCode.Decimal64: + case QwpTypeCode.Decimal128: + case QwpTypeCode.Decimal256: + Debug.Assert(col.DecimalScaleSet, + "DECIMAL column with no non-null values must still have a scale set on first typing"); + buf.WriteByte(col.DecimalScale); + return; + case QwpTypeCode.Geohash: + Debug.Assert(col.GeohashPrecisionBits >= 1 && col.GeohashPrecisionBits <= 60, + "GEOHASH column precision must be in [1, 60] before encode"); + buf.WriteVarint((ulong)col.GeohashPrecisionBits); + return; + default: + return; + } + } + + switch (col.TypeCode) + { + case QwpTypeCode.Boolean: + var boolBytes = (n + 7) >> 3; + buf.WriteBytes(col.BoolData!.AsSpan(0, boolBytes)); + break; + + case QwpTypeCode.Timestamp: + case QwpTypeCode.TimestampNanos: + WriteTimestampColumnGorilla(buf, col, n); + break; + + case QwpTypeCode.Byte: + case QwpTypeCode.Short: + case QwpTypeCode.Int: + case QwpTypeCode.Long: + case QwpTypeCode.Float: + case QwpTypeCode.Double: + case QwpTypeCode.Date: + case QwpTypeCode.Uuid: + case QwpTypeCode.Char: + case QwpTypeCode.IPv4: + case QwpTypeCode.Long256: + case QwpTypeCode.DoubleArray: + case QwpTypeCode.LongArray: + // Fixed-width primitives, LONG256, and arrays all store their wire-ready bytes + // back-to-back in FixedData. The encoder dumps them verbatim. + buf.WriteBytes(col.FixedData!.AsSpan(0, col.FixedLen)); + break; + + case QwpTypeCode.Varchar: + case QwpTypeCode.Binary: + // (n + 1) uint32 LE offsets — bulk-copy on LE hosts, scalar fallback on BE. + if (BitConverter.IsLittleEndian) + { + var offsetsBytes = System.Runtime.InteropServices.MemoryMarshal.AsBytes( + col.StrOffsets.AsSpan(0, n + 1)); + buf.WriteBytes(offsetsBytes); + } + else + { + for (var i = 0; i <= n; i++) + { + buf.WriteUInt32LittleEndian(col.StrOffsets![i]); + } + } + + buf.WriteBytes(col.StrData!.AsSpan(0, col.StrLen)); + break; + + case QwpTypeCode.Symbol: + // varint global ids, one per non-null row. + for (var i = 0; i < n; i++) + { + buf.WriteVarint((ulong)col.SymbolIds![i]); + } + + break; + + case QwpTypeCode.Decimal64: + case QwpTypeCode.Decimal128: + case QwpTypeCode.Decimal256: + // 1-byte scale prefix + (8|16|32) bytes per value, LE two's complement. + buf.WriteByte(col.DecimalScale); + buf.WriteBytes(col.FixedData!.AsSpan(0, col.FixedLen)); + break; + + case QwpTypeCode.Geohash: + // varint precision_bits + ceil(precision/8) × value_count packed bytes. + buf.WriteVarint((ulong)col.GeohashPrecisionBits); + buf.WriteBytes(col.FixedData!.AsSpan(0, col.FixedLen)); + break; + + default: + throw new IngressError(ErrorCode.InvalidApiCall, + $"encoder does not yet support type {col.TypeCode}"); + } + } + + private static void WriteTimestampColumnGorilla(FrameBuilder buf, QwpColumn col, int valueCount) + { + // FixedData stores values as little-endian int64. On LE hosts we can reinterpret the byte + // span directly; on BE hosts we'd need per-element byte-swapping. Use the LE branch and + // fall back to a per-element read otherwise so Gorilla works regardless of host endianness. + var bytes = col.FixedData!.AsSpan(0, valueCount * 8); + + Span timestamps; + long[]? rentedTimestamps = null; + + if (BitConverter.IsLittleEndian) + { + timestamps = MemoryMarshal.Cast(bytes); + } + else + { + rentedTimestamps = ArrayPool.Shared.Rent(valueCount); + timestamps = rentedTimestamps.AsSpan(0, valueCount); + for (var i = 0; i < valueCount; i++) + { + timestamps[i] = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(i * 8, 8)); + } + } + + var maxSize = QwpGorilla.MaxEncodedSize(valueCount); + var rented = ArrayPool.Shared.Rent(maxSize); + try + { + var written = QwpGorilla.Encode(rented.AsSpan(0, maxSize), timestamps); + buf.WriteBytes(rented.AsSpan(0, written)); + } + finally + { + ArrayPool.Shared.Return(rented); + if (rentedTimestamps is not null) + { + ArrayPool.Shared.Return(rentedTimestamps); + } + } + } + + private static void WriteString(FrameBuilder buf, string value) + { + if (value.Length == 0) + { + buf.WriteVarint(0); + return; + } + + var maxBytes = QwpConstants.StrictUtf8.GetMaxByteCount(value.Length); + if (maxBytes <= 256) + { + Span scratch = stackalloc byte[256]; + var written = QwpConstants.StrictUtf8.GetBytes(value, scratch); + buf.WriteVarint((ulong)written); + buf.WriteBytes(scratch.Slice(0, written)); + return; + } + + var rented = ArrayPool.Shared.Rent(maxBytes); + try + { + var written = QwpConstants.StrictUtf8.GetBytes(value, rented); + buf.WriteVarint((ulong)written); + buf.WriteBytes(rented.AsSpan(0, written)); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + /// + /// Internal byte buffer with simple grow-by-doubling and span access. Class (not struct) so + /// the encoder can pass it by reference without ref-struct contortions. + /// + internal sealed class FrameBuilder + { + private byte[] _buf; + + public FrameBuilder(int initialCapacity) + { + _buf = new byte[initialCapacity]; + } + + public int Length { get; private set; } + + /// Returns the underlying buffer's view of the encoded frame. + public ReadOnlyMemory WrittenMemory => _buf.AsMemory(0, Length); + + public void Reset() + { + // Buffer stays; only the write head moves back. Capacity grows monotonically across flushes. + Length = 0; + } + + public Span Allocate(int count) + { + EnsureCapacity(Length + count); + var span = _buf.AsSpan(Length, count); + Length += count; + return span; + } + + public void WriteByte(byte b) + { + EnsureCapacity(Length + 1); + _buf[Length++] = b; + } + + public void WriteBytes(ReadOnlySpan bytes) + { + bytes.CopyTo(Allocate(bytes.Length)); + } + + public void WriteUInt32LittleEndian(uint v) + { + BinaryPrimitives.WriteUInt32LittleEndian(Allocate(4), v); + } + + public void WriteVarint(ulong v) + { + EnsureCapacity(Length + QwpVarint.MaxBytes); + Length += QwpVarint.Write(_buf.AsSpan(Length), v); + } + + public Span AsSpan(int start, int length) + { + return _buf.AsSpan(start, length); + } + + public byte[] ToArray() + { + var result = new byte[Length]; + Array.Copy(_buf, result, Length); + return result; + } + + private void EnsureCapacity(int required) + { + if (required < 0) + { + throw new IngressError(ErrorCode.InvalidApiCall, + "encoder buffer requirement overflowed int.MaxValue"); + } + + if (_buf.Length >= required) + { + return; + } + + var newSize = (long)_buf.Length; + while (newSize < required) + { + newSize *= 2; + if (newSize > int.MaxValue) + { + newSize = int.MaxValue; + break; + } + } + + if (newSize < required) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"encoder buffer required size {required} exceeds the {int.MaxValue}-byte cap"); + } + + Array.Resize(ref _buf, (int) newSize); + } + } +} diff --git a/src/net-questdb-client/Qwp/QwpException.cs b/src/net-questdb-client/Qwp/QwpException.cs new file mode 100644 index 00000000..47e3b9f2 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpException.cs @@ -0,0 +1,96 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Exception raised when the QWP server reports a non-OK status. +/// +/// +/// Wraps the QWP-level and request alongside the +/// existing machinery, so callers that already handle +/// IngressError see this as one of those, while callers that want the QWP detail can +/// downcast. +/// +public sealed class QwpException : IngressError +{ + /// + /// Constructs a new . + /// + /// QWP status code from the server frame. + /// Batch sequence the server was responding to. -1 if not applicable. + /// UTF-8 message decoded from the server frame, or a synthetic message. + public QwpException(QwpStatusCode status, long sequence, string message) + : base(MapToErrorCode(status), Format(status, sequence, message)) + { + Status = status; + Sequence = sequence; + } + + /// + /// Constructs a new chaining the underlying cause. + /// + /// QWP status code from the server frame. + /// Batch sequence the server was responding to. -1 if not applicable. + /// UTF-8 message decoded from the server frame, or a synthetic message. + /// The underlying exception that triggered this error. + public QwpException(QwpStatusCode status, long sequence, string message, Exception inner) + : base(MapToErrorCode(status), Format(status, sequence, message), inner) + { + Status = status; + Sequence = sequence; + } + + /// QWP status code received from the server. + public QwpStatusCode Status { get; } + + /// + /// Request sequence the server was responding to. -1 when the response carries no + /// sequence (for example, durable-ack frames). + /// + public long Sequence { get; } + + private static string Format(QwpStatusCode status, long sequence, string message) + { + return sequence < 0 + ? $"{status}: {message}" + : $"{status} (seq={sequence}): {message}"; + } + + private static ErrorCode MapToErrorCode(QwpStatusCode status) + { + return status switch + { + QwpStatusCode.SchemaMismatch => ErrorCode.InvalidApiCall, + QwpStatusCode.ParseError => ErrorCode.ProtocolVersionError, + QwpStatusCode.SecurityError => ErrorCode.AuthError, + // WriteError, InternalError, and any unrecognised codes all map to the generic + // server-flush error. + _ => ErrorCode.ServerFlushError, + }; + } +} diff --git a/src/net-questdb-client/Qwp/QwpGorilla.cs b/src/net-questdb-client/Qwp/QwpGorilla.cs new file mode 100644 index 00000000..9a1bf682 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpGorilla.cs @@ -0,0 +1,410 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Gorilla delta-of-delta timestamp compression. +/// +/// +/// Output layout when called with FLAG_GORILLA on: +/// +/// encoding_flag (uint8): 0x00 uncompressed, 0x01 Gorilla. +/// Uncompressed: value_count × int64 LE. +/// +/// Gorilla: first_timestamp (int64 LE) + second_timestamp (int64 LE) + +/// bit-packed delta-of-deltas for timestamps 3..N. +/// +/// +/// +/// The encoder falls back to uncompressed mode when: +/// +/// fewer than 3 values are present (no DoDs to compress); +/// any DoD overflows the 32-bit signed integer range. +/// +/// +/// Bucket layout — bits LSB-first, prefix then signed value: +/// +/// DoD rangeprefix bits + value bits +/// == 00 (1 bit total) +/// [-64, 63]10 + 7-bit signed (9 bits total) +/// [-256, 255]110 + 9-bit signed (12 bits total) +/// [-2048, 2047]1110 + 12-bit signed (16 bits total) +/// otherwise1111 + 32-bit signed (36 bits total) +/// +/// +internal static class QwpGorilla +{ + /// Encoding-flag byte signalling uncompressed values follow. + public const byte EncodingUncompressed = 0x00; + + /// Encoding-flag byte signalling a Gorilla bit stream follows. + public const byte EncodingGorilla = 0x01; + + /// + /// Worst-case byte count for a Gorilla-encoded column with values. + /// + public static int MaxEncodedSize(int valueCount) + { + if (valueCount <= 0) + { + return 1; + } + + if (valueCount == 1) + { + return UncompressedSize(valueCount); + } + + // 1 (encoding flag) + 16 (first + second) + ceil((N - 2) × 36 / 8) for the worst-case fallback bucket. + var dodBytes = ((valueCount - 2) * 36 + 7) / 8; + var gorilla = 1 + 16 + dodBytes; + return Math.Max(gorilla, UncompressedSize(valueCount)); + } + + /// Size of the uncompressed encoding for values. + public static int UncompressedSize(int valueCount) + { + return 1 + 8 * valueCount; + } + + /// + /// Encodes into . Picks Gorilla or + /// uncompressed automatically; falls back to uncompressed on int32 DoD overflow or when + /// fewer than 3 values are present. + /// + /// Number of bytes written into . + public static int Encode(Span dest, ReadOnlySpan timestamps) + { + if (timestamps.Length < 3) + { + return EncodeUncompressed(dest, timestamps); + } + + var gorillaSize = TryEncodeGorilla(dest, timestamps); + if (gorillaSize >= 0) + { + return gorillaSize; + } + + return EncodeUncompressed(dest, timestamps); + } + + /// + /// Decodes a Gorilla / uncompressed timestamp column. The caller supplies the expected + /// from the row count − null count. + /// + /// Number of source bytes consumed. + public static int Decode(ReadOnlySpan source, Span dest, int valueCount) + { + if (valueCount <= 0) + { + return 0; + } + + if (source.Length < 1) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "Gorilla source truncated: missing encoding flag"); + } + + var flag = source[0]; + if (flag == EncodingUncompressed) + { + return DecodeUncompressed(source, dest, valueCount); + } + + if (flag != EncodingGorilla) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"Gorilla source: unknown encoding flag 0x{flag:X2}"); + } + + return DecodeGorilla(source, dest, valueCount); + } + + /// + /// Decodes into a little-endian destination — endianness-stable on big-endian + /// platforms unlike the overload which writes through native-endian + /// long storage. Use this when the caller wants to interpret the result through + /// BinaryPrimitives.ReadInt64LittleEndian. + /// + public static int DecodeToBytes(ReadOnlySpan source, Span dest, int valueCount) + { + if (valueCount <= 0) + { + return 0; + } + + if (source.Length < 1) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "Gorilla source truncated: missing encoding flag"); + } + + var flag = source[0]; + if (flag == EncodingUncompressed) + { + return DecodeUncompressedToBytes(source, dest, valueCount); + } + + if (flag != EncodingGorilla) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"Gorilla source: unknown encoding flag 0x{flag:X2}"); + } + + return DecodeGorillaToBytes(source, dest, valueCount); + } + + private static int DecodeUncompressedToBytes(ReadOnlySpan source, Span dest, int valueCount) + { + var expected = 1 + valueCount * 8; + if (source.Length < expected) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"uncompressed timestamp column truncated: need {expected} bytes, have {source.Length}"); + } + if (dest.Length < valueCount * 8) + { + throw new ArgumentException("dest too small", nameof(dest)); + } + source.Slice(1, valueCount * 8).CopyTo(dest); + return expected; + } + + private static int DecodeGorillaToBytes(ReadOnlySpan source, Span dest, int valueCount) + { + if (valueCount < 2) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + "Gorilla-encoded column requires at least two timestamps"); + } + + if (source.Length < 17) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + "Gorilla source truncated: missing first/second timestamps"); + } + if (dest.Length < valueCount * 8) + { + throw new ArgumentException("dest too small", nameof(dest)); + } + + var t0 = BinaryPrimitives.ReadInt64LittleEndian(source.Slice(1, 8)); + var t1 = BinaryPrimitives.ReadInt64LittleEndian(source.Slice(9, 8)); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(0, 8), t0); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(8, 8), t1); + + var reader = new QwpBitReader(source, 17); + var prevDelta = t1 - t0; + var prev = t1; + + for (var i = 2; i < valueCount; i++) + { + var dod = DecodeDoD(ref reader); + var delta = prevDelta + dod; + var val = prev + delta; + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(i * 8, 8), val); + prevDelta = delta; + prev = val; + } + + return reader.BytePosition; + } + + private static int EncodeUncompressed(Span dest, ReadOnlySpan timestamps) + { + dest[0] = EncodingUncompressed; + for (var i = 0; i < timestamps.Length; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(1 + i * 8, 8), timestamps[i]); + } + + return 1 + timestamps.Length * 8; + } + + /// + /// Tries Gorilla encoding. Returns the byte count, or -1 if any DoD overflows int32. + /// + private static int TryEncodeGorilla(Span dest, ReadOnlySpan timestamps) + { + dest[0] = EncodingGorilla; + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(1, 8), timestamps[0]); + BinaryPrimitives.WriteInt64LittleEndian(dest.Slice(9, 8), timestamps[1]); + + var writer = new QwpBitWriter(dest, 17); + var prevDelta = timestamps[1] - timestamps[0]; + + for (var i = 2; i < timestamps.Length; i++) + { + var delta = timestamps[i] - timestamps[i - 1]; + var dod = delta - prevDelta; + if (dod < int.MinValue || dod > int.MaxValue) + { + return -1; + } + + EncodeDoD(ref writer, (int)dod); + prevDelta = delta; + } + + return 17 + writer.FinishToByteBoundary(); + } + + private static void EncodeDoD(ref QwpBitWriter writer, int dod) + { + if (dod == 0) + { + writer.WriteBits(0, 1); + return; + } + + if (dod >= -64 && dod <= 63) + { + // prefix '10' LSB-first → bits 1, 0 → ulong value 0b01 = 1 + writer.WriteBits(0b01, 2); + writer.WriteBits((ulong)dod & 0x7FUL, 7); + return; + } + + if (dod >= -256 && dod <= 255) + { + // prefix '110' LSB-first → bits 1, 1, 0 → ulong value 0b011 = 3 + writer.WriteBits(0b011, 3); + writer.WriteBits((ulong)dod & 0x1FFUL, 9); + return; + } + + if (dod >= -2048 && dod <= 2047) + { + // prefix '1110' LSB-first → bits 1, 1, 1, 0 → ulong value 0b0111 = 7 + writer.WriteBits(0b0111, 4); + writer.WriteBits((ulong)dod & 0xFFFUL, 12); + return; + } + + // prefix '1111' → ulong value 0b1111 = 15 + writer.WriteBits(0b1111, 4); + writer.WriteBits((uint)dod, 32); + } + + private static int DecodeUncompressed(ReadOnlySpan source, Span dest, int valueCount) + { + var expected = 1 + valueCount * 8; + if (source.Length < expected) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"uncompressed timestamp column truncated: need {expected} bytes, have {source.Length}"); + } + if (dest.Length < valueCount) + { + throw new ArgumentException("dest too small", nameof(dest)); + } + + for (var i = 0; i < valueCount; i++) + { + dest[i] = BinaryPrimitives.ReadInt64LittleEndian(source.Slice(1 + i * 8, 8)); + } + + return expected; + } + + private static int DecodeGorilla(ReadOnlySpan source, Span dest, int valueCount) + { + if (valueCount < 2) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + "Gorilla-encoded column requires at least two timestamps"); + } + if (dest.Length < valueCount) + { + throw new ArgumentException("dest too small", nameof(dest)); + } + + if (source.Length < 17) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + "Gorilla source truncated: missing first/second timestamps"); + } + + dest[0] = BinaryPrimitives.ReadInt64LittleEndian(source.Slice(1, 8)); + dest[1] = BinaryPrimitives.ReadInt64LittleEndian(source.Slice(9, 8)); + + var reader = new QwpBitReader(source, 17); + var prevDelta = dest[1] - dest[0]; + + for (var i = 2; i < valueCount; i++) + { + var dod = DecodeDoD(ref reader); + var delta = prevDelta + dod; + dest[i] = dest[i - 1] + delta; + prevDelta = delta; + } + + return reader.BytePosition; + } + + private static long DecodeDoD(ref QwpBitReader reader) + { + if (reader.ReadBits(1) == 0) + { + return 0; + } + + if (reader.ReadBits(1) == 0) + { + // prefix '10' → 7-bit signed + return SignExtend(reader.ReadBits(7), 7); + } + + if (reader.ReadBits(1) == 0) + { + // prefix '110' → 9-bit signed + return SignExtend(reader.ReadBits(9), 9); + } + + if (reader.ReadBits(1) == 0) + { + // prefix '1110' → 12-bit signed + return SignExtend(reader.ReadBits(12), 12); + } + + // prefix '1111' → 32-bit signed + return (int)reader.ReadBits(32); + } + + private static long SignExtend(ulong raw, int bitCount) + { + var sign = 1UL << (bitCount - 1); + if ((raw & sign) != 0) + { + return (long)raw - (long)(1UL << bitCount); + } + + return (long)raw; + } +} diff --git a/src/net-questdb-client/Qwp/QwpHostHealthTracker.cs b/src/net-questdb-client/Qwp/QwpHostHealthTracker.cs new file mode 100644 index 00000000..2848df0f --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpHostHealthTracker.cs @@ -0,0 +1,256 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using System.Collections.Generic; + +namespace QuestDB.Qwp; + +internal enum QwpHostState +{ + Unknown, + Healthy, + TransientReject, + TransportError, + TopologyReject, +} + +internal enum QwpZoneTier +{ + Same, + Unknown, + Other, +} + +/// +/// Per-sender host-ranking bookkeeping. returns the highest-priority +/// (state, zone) host not yet tried this round. +/// +internal sealed class QwpHostHealthTracker +{ + private static readonly QwpHostState[] StatePriorityOrder = + { + QwpHostState.Healthy, + QwpHostState.Unknown, + QwpHostState.TransientReject, + QwpHostState.TransportError, + QwpHostState.TopologyReject, + }; + + private static readonly QwpZoneTier[] ZonePriorityOrder = + { + QwpZoneTier.Same, + QwpZoneTier.Unknown, + QwpZoneTier.Other, + }; + + // Shared across SF reconnect + drainer pool + egress query client; guards _states + _attemptedThisRound + _zoneTiers. + private readonly object _lock = new(); + private readonly bool[] _attemptedThisRound; + private readonly string[] _hosts; + private readonly QwpHostState[] _states; + private readonly QwpZoneTier[] _zoneTiers; + private readonly long[] _lastSuccessEpoch; + private readonly string? _clientZone; + private readonly bool _zoneCollapseToSame; + private long _successCounter; + + public QwpHostHealthTracker(IReadOnlyList hosts) + : this(hosts, clientZone: null, targetIsPrimary: false) + { + } + + public QwpHostHealthTracker(IReadOnlyList hosts, string? clientZone, bool targetIsPrimary) + { + if (hosts == null) throw new ArgumentNullException(nameof(hosts)); + if (hosts.Count == 0) throw new ArgumentException("hosts must be non-empty", nameof(hosts)); + _hosts = new string[hosts.Count]; + for (var i = 0; i < hosts.Count; i++) _hosts[i] = hosts[i]; + _states = new QwpHostState[_hosts.Length]; + _attemptedThisRound = new bool[_hosts.Length]; + _lastSuccessEpoch = new long[_hosts.Length]; + _zoneTiers = new QwpZoneTier[_hosts.Length]; + + _clientZone = string.IsNullOrEmpty(clientZone) ? null : clientZone; + _zoneCollapseToSame = _clientZone is null || targetIsPrimary; + var defaultTier = _zoneCollapseToSame ? QwpZoneTier.Same : QwpZoneTier.Unknown; + for (var i = 0; i < _zoneTiers.Length; i++) _zoneTiers[i] = defaultTier; + } + + public int Count => _hosts.Length; + + /// True once every host has been attempted in the current round. + public bool IsRoundExhausted + { + get + { + lock (_lock) + { + for (var i = 0; i < _attemptedThisRound.Length; i++) + { + if (!_attemptedThisRound[i]) return false; + } + return true; + } + } + } + + public string GetHost(int index) => _hosts[index]; + + public QwpHostState GetState(int index) + { + lock (_lock) return _states[index]; + } + + public QwpZoneTier GetZoneTier(int index) + { + lock (_lock) return _zoneTiers[index]; + } + + /// Returns the highest-priority host not yet attempted this round, or -1 when exhausted. + public int PickNext() + { + lock (_lock) + { + foreach (var statePriority in StatePriorityOrder) + { + foreach (var zonePriority in ZonePriorityOrder) + { + for (var i = 0; i < _hosts.Length; i++) + { + if (!_attemptedThisRound[i] + && _states[i] == statePriority + && _zoneTiers[i] == zonePriority) + { + return i; + } + } + } + } + + return -1; + } + } + + public void RecordSuccess(int hostIndex) + { + lock (_lock) + { + _states[hostIndex] = QwpHostState.Healthy; + _attemptedThisRound[hostIndex] = true; + _lastSuccessEpoch[hostIndex] = ++_successCounter; + } + } + + public void RecordRoleReject(int hostIndex, bool transient) + { + lock (_lock) + { + _states[hostIndex] = transient ? QwpHostState.TransientReject : QwpHostState.TopologyReject; + _attemptedThisRound[hostIndex] = true; + } + } + + public void RecordTransportError(int hostIndex) + { + lock (_lock) + { + _states[hostIndex] = QwpHostState.TransportError; + _attemptedThisRound[hostIndex] = true; + } + } + + /// + /// Records that a previously-successful connection failed mid-stream (send or receive). Demotes + /// to so the next + /// with forgetClassifications=true doesn't preserve the dead + /// host as the sticky-priority entry. + /// + public void RecordMidStreamFailure(int hostIndex) + { + lock (_lock) + { + if (_states[hostIndex] == QwpHostState.Healthy) + { + _states[hostIndex] = QwpHostState.TransportError; + } + } + } + + /// + /// Records the server-advertised zone for a host; null/empty is a no-op. Persists across + /// . + /// + public void RecordZone(int hostIndex, string? zoneId) + { + if (string.IsNullOrEmpty(zoneId)) return; + lock (_lock) + { + _zoneTiers[hostIndex] = _zoneCollapseToSame + || string.Equals(zoneId, _clientZone, StringComparison.OrdinalIgnoreCase) + ? QwpZoneTier.Same + : QwpZoneTier.Other; + } + } + + /// + /// Clears the attempted-this-round flags. With , also + /// resets every non-sticky host — including — to + /// , preserving only the most-recent same-zone + /// host as the sticky pick. Role-rejected hosts are + /// deliberately re-probed on the next round: a replica can be promoted (or a primary demoted), + /// so a stale role classification must not pin a host out permanently. Within a single round + /// the ranking still applies via . + /// + public void BeginRound(bool forgetClassifications) + { + lock (_lock) + { + var stickyIndex = -1; + if (forgetClassifications) + { + var bestEpoch = 0L; + for (var i = 0; i < _hosts.Length; i++) + { + if (_states[i] == QwpHostState.Healthy + && _zoneTiers[i] == QwpZoneTier.Same + && _lastSuccessEpoch[i] > bestEpoch) + { + bestEpoch = _lastSuccessEpoch[i]; + stickyIndex = i; + } + } + } + + for (var i = 0; i < _hosts.Length; i++) + { + _attemptedThisRound[i] = false; + if (forgetClassifications && i != stickyIndex) + { + _states[i] = QwpHostState.Unknown; + } + } + } + } +} diff --git a/src/net-questdb-client/Qwp/QwpIngressRoleRejectedException.cs b/src/net-questdb-client/Qwp/QwpIngressRoleRejectedException.cs new file mode 100644 index 00000000..6e6cbfa7 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpIngressRoleRejectedException.cs @@ -0,0 +1,75 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Raised when the server rejects a /write/v4 WebSocket upgrade with +/// 421 Misdirected Request + X-QuestDB-Role. Carries the role so +/// the host-health tracker can distinguish transient (PRIMARY_CATCHUP) from +/// structural (REPLICA) topology rejects. +/// +public sealed class QwpIngressRoleRejectedException : IngressError +{ + public QwpIngressRoleRejectedException(string role, Uri uri, string? zone = null, Exception? innerException = null) + : base(ErrorCode.SocketError, + $"WebSocket ingress upgrade rejected by role={role} at {uri}", + innerException) + { + Role = role; + Uri = uri; + Zone = zone; + } + + /// X-QuestDB-Role value as advertised by the server (uppercase ASCII). + public string Role { get; } + + /// The endpoint that returned the role-reject response. + public Uri Uri { get; } + + /// + /// X-QuestDB-Zone value advertised by the server on the 421 response (case-insensitive + /// identifier such as eu-west-1a); null when the header was absent or empty. + /// + public string? Zone { get; } + + /// + /// true when the role indicates a transient promotion-in-progress state + /// (PRIMARY_CATCHUP); the same endpoint is likely to accept writes once + /// the catchup completes. + /// + public bool IsTransient => + string.Equals(Role, QwpConstants.RolePrimaryCatchupName, StringComparison.OrdinalIgnoreCase); + + /// + /// true when the role is structurally unable to accept writes (REPLICA); + /// retrying the same endpoint will not help until topology changes. + /// + public bool IsTopological => + string.Equals(Role, QwpConstants.RoleReplicaName, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/net-questdb-client/Qwp/QwpProtocolViolationException.cs b/src/net-questdb-client/Qwp/QwpProtocolViolationException.cs new file mode 100644 index 00000000..5bce7442 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpProtocolViolationException.cs @@ -0,0 +1,78 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using System.Net.WebSockets; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Terminal exception raised when the server sends a WebSocket CLOSE frame carrying a +/// protocol-violation close code. The cursor engine MUST NOT reconnect on this — the +/// server is signalling a structural problem with the bytes that just left the client, +/// and replaying them produces the same outcome. +/// +/// +/// The qualifying close codes are 1002 PROTOCOL_ERROR, 1003 UNSUPPORTED_DATA, +/// 1007 INVALID_PAYLOAD_DATA, 1008 POLICY_VIOLATION, 1009 MESSAGE_TOO_BIG, +/// and 1010 MANDATORY_EXTENSION. All other CLOSE codes are reconnect-eligible. +/// +public sealed class QwpProtocolViolationException : IngressError +{ + /// Constructs the exception from the server-sent CLOSE status and description. + public QwpProtocolViolationException(WebSocketCloseStatus status, string? reason) + : base(ErrorCode.ProtocolViolation, FormatMessage(status, reason)) + { + CloseStatus = status; + Reason = reason; + } + + /// The qualifying WebSocket CLOSE status code. + public WebSocketCloseStatus CloseStatus { get; } + + /// Operator-supplied CLOSE description; null when the server omitted one. + public string? Reason { get; } + + /// + /// true for the close-code set that terminates the cursor engine; false + /// for codes routed back into the reconnect loop. + /// + public static bool IsProtocolViolationCode(WebSocketCloseStatus status) => status is + WebSocketCloseStatus.ProtocolError + or WebSocketCloseStatus.InvalidMessageType + or WebSocketCloseStatus.InvalidPayloadData + or WebSocketCloseStatus.PolicyViolation + or WebSocketCloseStatus.MessageTooBig + or WebSocketCloseStatus.MandatoryExtension; + + private static string FormatMessage(WebSocketCloseStatus status, string? reason) + { + var code = ((int)status).ToString(System.Globalization.CultureInfo.InvariantCulture); + return string.IsNullOrEmpty(reason) + ? $"ws-close[{code}]: " + : $"ws-close[{code}]: {reason}"; + } +} diff --git a/src/net-questdb-client/Qwp/QwpResponse.cs b/src/net-questdb-client/Qwp/QwpResponse.cs new file mode 100644 index 00000000..0c327943 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpResponse.cs @@ -0,0 +1,296 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Per-table seqTxn entry carried in OK and durable-ACK frames when +/// request_durable_ack is enabled. +/// +internal readonly record struct QwpTableEntry(string TableName, long SeqTxn); + +/// +/// A parsed QWP server response. +/// +/// +/// Three on-wire shapes are supported: +/// +/// +/// OK — 11+ bytes: uint8 status (0x00) + int64 sequence + +/// uint16 tableCount + tableCount repeating +/// [uint16 nameLen + name + int64 seqTxn] entries. The sequence is the +/// cumulative ACK watermark; every batch with seq ≤ has +/// succeeded. +/// +/// +/// Durable-ACKuint8 status (0x02) + uint16 tableCount + entries. +/// Carries no batch sequence; is set to -1. +/// +/// +/// Error — 11 + msg_len bytes: status + sequence + uint16 msg_len + UTF-8 +/// message. The decoded is truncated to 1024 bytes (with a +/// trailing ellipsis) when the server sends a longer one; the frame still parses as +/// a normal recoverable error. The free-form message is decoded leniently — +/// malformed bytes become U+FFFD rather than failing the parse. +/// +/// +/// +/// Strict validation: the parser rejects empty table names, lying lengths, and any +/// trailing bytes after the last entry. +/// +internal readonly struct QwpResponse +{ + private static readonly QwpTableEntry[] EmptyEntries = Array.Empty(); + private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + // Lossy: a malformed byte in the free-form error message must not escalate a recoverable error. + private static readonly UTF8Encoding LossyUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false); + + public QwpResponse(QwpStatusCode status, long sequence, string message, QwpTableEntry[] tableEntries) + { + Status = status; + Sequence = sequence; + Message = message; + TableEntries = tableEntries; + } + + /// The status code from the response frame. + public QwpStatusCode Status { get; } + + /// + /// For OK and error frames, the request sequence number being acknowledged. Cumulative for + /// OK; specific to the failing batch for errors. -1 for durable-ACK frames. + /// + public long Sequence { get; } + + /// UTF-8 decoded message text. Empty for OK and durable-ACK frames. + public string Message { get; } + + /// + /// Per-table seqTxn watermarks when present. Empty for OK frames with tableCount=0 + /// and for error frames. + /// + public IReadOnlyList TableEntries { get; } + + /// True when this is a successful response. + public bool IsOk => Status == QwpStatusCode.Ok; + + /// True when this carries durable-upload watermarks. + public bool IsDurableAck => Status == QwpStatusCode.DurableAck; + + /// Builds a describing this error response. + public QwpException ToException() + { + return new QwpException(Status, Sequence, Message); + } + + /// Parses a single QWP response frame from . + /// If the frame is malformed or carries an unsupported status. + public static QwpResponse Parse(ReadOnlySpan frame) + { + if (frame.Length < 1) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "QWP response frame is empty"); + } + + var statusByte = frame[0]; + var status = (QwpStatusCode)statusByte; + + if (status == QwpStatusCode.Ok) + { + return ParseOk(frame); + } + + if (status == QwpStatusCode.DurableAck) + { + return ParseDurableAck(frame); + } + + if (IsKnownEgressOnlyStatus(status)) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP ingest response carries egress-only status 0x{statusByte:X2}"); + } + + return ParseError(status, frame); + } + + // Cancelled (0x0A) / LimitExceeded (0x0B) are defined for egress QUERY_ERROR; they must not + // appear on the ingest reply channel. Other undefined bytes are left through so future status + // codes the server adds are forward-compatible — the engine routes them via the Unknown + // category which always halts (see QwpErrorClassifier). + private static bool IsKnownEgressOnlyStatus(QwpStatusCode status) + { + return status is QwpStatusCode.Cancelled or QwpStatusCode.LimitExceeded; + } + + private static QwpResponse ParseOk(ReadOnlySpan frame) + { + // Spec: status (1) + sequence (8) + tableCount (2) + entries. Minimum 11 bytes; matches Java. + const int headerSize = QwpConstants.OffsetTableCountInOkAck + 2; + if (frame.Length < headerSize) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP OK response has invalid size {frame.Length}; must be ≥ {headerSize}"); + } + + var sequence = BinaryPrimitives.ReadInt64LittleEndian(frame.Slice(1, 8)); + var tableCount = BinaryPrimitives.ReadUInt16LittleEndian(frame.Slice(QwpConstants.OffsetTableCountInOkAck, 2)); + var entries = ParseTableEntries(frame.Slice(headerSize), tableCount); + return new QwpResponse(QwpStatusCode.Ok, sequence, string.Empty, entries); + } + + private static QwpResponse ParseDurableAck(ReadOnlySpan frame) + { + // Durable-ACK: status (1) + tableCount (2) + entries. No batch sequence. + const int headerSize = 3; + if (frame.Length < headerSize) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP durable-ACK response truncated: got {frame.Length} bytes, header alone needs {headerSize}"); + } + + var tableCount = BinaryPrimitives.ReadUInt16LittleEndian(frame.Slice(1, 2)); + var entries = ParseTableEntries(frame.Slice(headerSize), tableCount); + return new QwpResponse(QwpStatusCode.DurableAck, sequence: -1L, string.Empty, entries); + } + + private static QwpResponse ParseError(QwpStatusCode status, ReadOnlySpan frame) + { + if (frame.Length < QwpConstants.ErrorAckHeaderSize) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP error response truncated: got {frame.Length} bytes, header alone needs {QwpConstants.ErrorAckHeaderSize}"); + } + + var seq = BinaryPrimitives.ReadInt64LittleEndian(frame.Slice(1, 8)); + var msgLen = BinaryPrimitives.ReadUInt16LittleEndian(frame.Slice(9, 2)); + + var expectedTotal = QwpConstants.ErrorAckHeaderSize + msgLen; + if (frame.Length != expectedTotal) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP error response size mismatch: header+message expects {expectedTotal} bytes, got {frame.Length}"); + } + + string message; + if (msgLen == 0) + { + message = string.Empty; + } + else + { + // An over-cap message is a legitimate (recoverable) server error, not a protocol + // violation. Decode only the first MaxErrorMessageBytes; framing already consumed + // the full msgLen above. + var truncated = msgLen > QwpConstants.MaxErrorMessageBytes; + var msgBytes = frame.Slice(QwpConstants.ErrorAckHeaderSize, (int)msgLen); + if (truncated) + { + msgBytes = msgBytes.Slice(0, QwpConstants.MaxErrorMessageBytes); + } + + message = LossyUtf8.GetString(msgBytes); + + if (truncated) + { + message += "…"; + } + } + + return new QwpResponse(status, seq, message, EmptyEntries); + } + + private static QwpTableEntry[] ParseTableEntries(ReadOnlySpan bytes, int expectedCount) + { + if (expectedCount == 0) + { + if (bytes.Length != 0) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP response: tableCount=0 but {bytes.Length} trailing bytes follow"); + } + + return EmptyEntries; + } + + var entries = new QwpTableEntry[expectedCount]; + var pos = 0; + for (var i = 0; i < expectedCount; i++) + { + if (pos + 2 > bytes.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP per-table entry {i}: truncated before name length"); + } + + var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(pos, 2)); + pos += 2; + + if (nameLen == 0) + { + // Empty table names would silently merge into a single bucket and poison per-table + // tracking. Reject as a server protocol violation. + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP per-table entry {i}: empty table name"); + } + + if (pos + nameLen + 8 > bytes.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP per-table entry {i}: declared length {nameLen} runs past frame end"); + } + + string name; + try + { + name = StrictUtf8.GetString(bytes.Slice(pos, nameLen)); + } + catch (DecoderFallbackException ex) + { + throw new IngressError(ErrorCode.InvalidUtf8, + $"QWP per-table entry {i}: invalid UTF-8 table name", ex); + } + pos += nameLen; + + var seqTxn = BinaryPrimitives.ReadInt64LittleEndian(bytes.Slice(pos, 8)); + pos += 8; + + entries[i] = new QwpTableEntry(name, seqTxn); + } + + if (pos != bytes.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP response: {bytes.Length - pos} trailing bytes after the last per-table entry"); + } + + return entries; + } + +} diff --git a/src/net-questdb-client/Qwp/QwpSymbolDictionary.cs b/src/net-questdb-client/Qwp/QwpSymbolDictionary.cs new file mode 100644 index 00000000..e5217463 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpSymbolDictionary.cs @@ -0,0 +1,173 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Connection-scoped, monotonically growing symbol dictionary used in +/// mode. +/// +/// +/// Each unique symbol value is assigned a sequential 0-based integer id the first time it is +/// seen. On every flush only the *delta* (newly added entries since the last successful flush) +/// is transmitted on the wire. Symbol columns then +/// reference values by their global id (varint) instead of carrying a per-table dictionary. +/// +/// Lifecycle: +/// +/// assigns ids; called from the user thread per row. +/// moves the watermark forward after a successful flush. +/// drops uncommitted entries when a flush failed. +/// clears everything; called when the wire connection resets. +/// +/// +internal sealed class QwpSymbolDictionary +{ + private readonly Dictionary _ids = new(StringComparer.Ordinal); + private readonly List _values = new(); +#if NET9_0_OR_GREATER + private readonly Dictionary.AlternateLookup> _idsLookup; + + public QwpSymbolDictionary() + { + _idsLookup = _ids.GetAlternateLookup>(); + } +#else + public QwpSymbolDictionary() + { + } +#endif + + private int _committedCount; + + /// Total number of entries assigned (committed + uncommitted). + public int Count => _values.Count; + + /// Number of entries the server has acknowledged. + public int CommittedCount => _committedCount; + + /// Starting index of the on-wire delta block (= ). + public int DeltaStart => _committedCount; + + /// Number of entries in the on-wire delta block (= - ). + public int DeltaCount => _values.Count - _committedCount; + + /// + /// Returns the global id for , allocating one on first sight. + /// + public int Add(ReadOnlySpan value) + { + int id; +#if NET9_0_OR_GREATER + if (_idsLookup.TryGetValue(value, out id)) + { + return id; + } +#else + var probeKey = value.ToString(); + if (_ids.TryGetValue(probeKey, out id)) + { + return id; + } +#endif + + // First sighting of this value. The symbol dictionary is re-encoded into every + // self-sufficient frame, so a value that is not valid UTF-8 (e.g. a lone surrogate) would + // throw from the encoder on every flush and permanently wedge the sender. Validate once, + // here, before the value is stored — repeated values return via the fast path above and + // never reach this check, so the hot path pays nothing. + try + { + _ = QwpStrictUtf8.Encoding.GetByteCount(value); + } + catch (System.Text.EncoderFallbackException ex) + { + throw new IngressError(ErrorCode.InvalidName, + "symbol value is not valid UTF-8 (lone surrogate)", ex); + } + + var stored = value.ToString(); + id = _values.Count; + _values.Add(stored); + _ids[stored] = id; + return id; + } + + /// Returns the symbol value at the given global id. + public string GetSymbol(int id) + { + if (id < 0 || id >= _values.Count) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"symbol id {id} out of range [0, {_values.Count})"); + } + return _values[id]; + } + + /// Advances the committed watermark; clears the delta. + public void Commit() + { + _committedCount = _values.Count; + } + + /// + /// Drops uncommitted entries; reverts ids issued since the last . + /// Called when a flush failed and the same delta will be re-emitted. + /// + public void Rollback() + { + RollbackTo(_committedCount); + } + + /// + /// Drops entries until equals . + /// must be ≥ . + /// + public void RollbackTo(int targetCount) + { + if (targetCount < _committedCount) + { + throw new ArgumentOutOfRangeException(nameof(targetCount), + "cannot roll back below the committed watermark"); + } + + while (_values.Count > targetCount) + { + var last = _values.Count - 1; + _ids.Remove(_values[last]); + _values.RemoveAt(last); + } + } + + /// Clears all state. Called on connection reset. + public void Reset() + { + _ids.Clear(); + _values.Clear(); + _committedCount = 0; + } +} diff --git a/src/net-questdb-client/Qwp/QwpTableBuffer.cs b/src/net-questdb-client/Qwp/QwpTableBuffer.cs new file mode 100644 index 00000000..74f306a8 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpTableBuffer.cs @@ -0,0 +1,590 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Numerics; +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +internal static class QwpStrictUtf8 +{ + internal static readonly UTF8Encoding Encoding = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); +} + +/// +/// Per-table columnar buffer used by the WebSocket sender. +/// +/// +/// Owns an ordered list of instances and the table's row count. +/// +/// Per-row state machine: +/// +/// User code calls AppendXxx to set values for the current row, in any order. +/// User code calls one of the At* methods to commit the row. Untouched columns are +/// null-padded; the designated-timestamp column receives the supplied timestamp. +/// +/// +/// Schemas always travel inline on the wire (no schema-id reference mechanism), so the buffer +/// no longer carries a schema-id slot. +/// +internal sealed class QwpTableBuffer +{ + private readonly Dictionary _columnIndex = new(StringComparer.OrdinalIgnoreCase); +#if NET9_0_OR_GREATER + private readonly Dictionary.AlternateLookup> _columnIndexLookup; +#endif + private readonly List _columns = new(); + private readonly int _maxNameLengthBytes; + + private bool[] _touchedInCurrentRow = new bool[8]; + + private int _committedColumnCount; + private QwpColumn.Savepoint[] _rowSavepoints = new QwpColumn.Savepoint[8]; + private QwpColumn.Savepoint? _designatedSavepoint; + private bool _designatedCreatedInCurrentRow; + + /// + /// Constructs a new empty buffer. + /// + /// UTF-8 byte length must be ≤ . + /// Override for the name-length limit; defaults to the spec value. + public QwpTableBuffer(string tableName, int maxNameLengthBytes = QwpConstants.MaxNameLengthBytes) + { + if (string.IsNullOrEmpty(tableName)) + { + throw new IngressError(ErrorCode.InvalidName, "table name must not be empty"); + } + + int nameByteCount; + try + { + nameByteCount = QwpStrictUtf8.Encoding.GetByteCount(tableName); + } + catch (EncoderFallbackException ex) + { + throw new IngressError(ErrorCode.InvalidName, + "table name is not valid UTF-8 (lone surrogate)", ex); + } + if (nameByteCount > maxNameLengthBytes) + { + throw new IngressError(ErrorCode.InvalidName, + $"table name exceeds {maxNameLengthBytes} UTF-8 bytes (got {nameByteCount})"); + } + + TableName = tableName; + _maxNameLengthBytes = maxNameLengthBytes; +#if NET9_0_OR_GREATER + _columnIndexLookup = _columnIndex.GetAlternateLookup>(); +#endif + } + + /// Table name as it appears on the wire. + public string TableName { get; } + + /// + /// Number of fully-committed rows. Increments on each At* call. + /// + public int RowCount { get; private set; } + + /// + /// User-declared data columns in declaration order. The designated-timestamp column is + /// not included here; access it via . + /// + public IReadOnlyList Columns => _columns; + + /// + /// The designated-timestamp column (empty wire name + TIMESTAMP / TIMESTAMP_NANOS type), + /// or null if no At* has been called yet. Always emitted last when encoding. + /// + public QwpColumn? DesignatedTimestampColumn { get; private set; } + + /// Total column count, including the designated-timestamp column if present. + public int TotalColumnCount => _columns.Count + (DesignatedTimestampColumn is null ? 0 : 1); + + /// True when at least one column has been touched in the current row. + public bool HasPendingRow { get; private set; } + + /// Raw bytes accumulated across every column's backing buffers, including the designated timestamp. + public long GetBufferedBytes() + { + long bytes = 0; + for (var i = 0; i < _columns.Count; i++) + { + bytes += _columns[i].BufferedBytes; + } + + if (DesignatedTimestampColumn is not null) + { + bytes += DesignatedTimestampColumn.BufferedBytes; + } + + return bytes; + } + + /// Append a boolean value to the named column. + public void AppendBool(ReadOnlySpan columnName, bool value) + { + try { GetOrCreateColumn(columnName)?.AppendBool(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a signed byte. + public void AppendByte(ReadOnlySpan columnName, sbyte value) + { + try { GetOrCreateColumn(columnName)?.AppendByte(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a 16-bit signed integer. + public void AppendShort(ReadOnlySpan columnName, short value) + { + try { GetOrCreateColumn(columnName)?.AppendShort(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a 32-bit signed integer. + public void AppendInt(ReadOnlySpan columnName, int value) + { + try { GetOrCreateColumn(columnName)?.AppendInt(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a 64-bit signed integer. + public void AppendLong(ReadOnlySpan columnName, long value) + { + try { GetOrCreateColumn(columnName)?.AppendLong(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a single-precision float. + public void AppendFloat(ReadOnlySpan columnName, float value) + { + try { GetOrCreateColumn(columnName)?.AppendFloat(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a double-precision float. + public void AppendDouble(ReadOnlySpan columnName, double value) + { + try { GetOrCreateColumn(columnName)?.AppendDouble(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a TIMESTAMP value (microseconds since epoch) to a non-designated column. + public void AppendTimestampMicros(ReadOnlySpan columnName, long micros) + { + try { GetOrCreateColumn(columnName)?.AppendTimestampMicros(micros); } catch { CancelCurrentRow(); throw; } + } + + /// Append a TIMESTAMP_NANOS value (nanoseconds since epoch) to a non-designated column. + public void AppendTimestampNanos(ReadOnlySpan columnName, long nanos) + { + try { GetOrCreateColumn(columnName)?.AppendTimestampNanos(nanos); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DATE value (milliseconds since epoch). + public void AppendDateMillis(ReadOnlySpan columnName, long millis) + { + try { GetOrCreateColumn(columnName)?.AppendDateMillis(millis); } catch { CancelCurrentRow(); throw; } + } + + /// Append a UUID. + public void AppendUuid(ReadOnlySpan columnName, Guid value) + { + try { GetOrCreateColumn(columnName)?.AppendUuid(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a single UTF-16 code unit. + public void AppendChar(ReadOnlySpan columnName, char value) + { + try { GetOrCreateColumn(columnName)?.AppendChar(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a length-prefixed UTF-8 string. + public void AppendVarchar(ReadOnlySpan columnName, ReadOnlySpan value) + { + try { GetOrCreateColumn(columnName)?.AppendVarchar(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a SYMBOL value as a global dictionary id. + public void AppendSymbol(ReadOnlySpan columnName, int globalId) + { + try { GetOrCreateColumn(columnName)?.AppendSymbol(globalId); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL64 value. The first call locks the column scale. + public void AppendDecimal64(ReadOnlySpan columnName, decimal value) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal64(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL128 value. The first call locks the column scale. + public void AppendDecimal128(ReadOnlySpan columnName, decimal value) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal128(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL256 value. The first call locks the column scale. + public void AppendDecimal256(ReadOnlySpan columnName, decimal value) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal256(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL64 value coerced to the explicit scale (round half away from zero). + public void AppendDecimal64(ReadOnlySpan columnName, decimal value, byte scale) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal64(value, scale); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL128 value coerced to the explicit scale (round half away from zero). + public void AppendDecimal128(ReadOnlySpan columnName, decimal value, byte scale) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal128(value, scale); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL256 value coerced to the explicit scale (round half away from zero). + public void AppendDecimal256(ReadOnlySpan columnName, decimal value, byte scale) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal256(value, scale); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL64 value as the unscaled int64 with explicit scale. + public void AppendDecimal64(ReadOnlySpan columnName, long unscaledValue, byte scale) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal64(unscaledValue, scale); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL128 value: lo = unsigned low 64 bits, hi = signed high 64 bits. + public void AppendDecimal128(ReadOnlySpan columnName, long lo, long hi, byte scale) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal128(lo, hi, scale); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DECIMAL256 value: l0l2 unsigned low limbs, l3 signed high limb. + public void AppendDecimal256(ReadOnlySpan columnName, long l0, long l1, long l2, long l3, byte scale) + { + try { GetOrCreateColumn(columnName)?.AppendDecimal256(l0, l1, l2, l3, scale); } catch { CancelCurrentRow(); throw; } + } + + /// Append a BINARY value as opaque bytes (no UTF-8 contract). + public void AppendBinary(ReadOnlySpan columnName, ReadOnlySpan value) + { + try { GetOrCreateColumn(columnName)?.AppendBinary(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append an IPv4 address as 4 bytes little-endian. + public void AppendIPv4(ReadOnlySpan columnName, uint addr) + { + try { GetOrCreateColumn(columnName)?.AppendIPv4(addr); } catch { CancelCurrentRow(); throw; } + } + + /// Append a non-negative LONG256 value (≤ 256 bits). + public void AppendLong256(ReadOnlySpan columnName, BigInteger value) + { + try { GetOrCreateColumn(columnName)?.AppendLong256(value); } catch { CancelCurrentRow(); throw; } + } + + /// Append a GEOHASH value. The first call locks the column precision (in bits). + public void AppendGeohash(ReadOnlySpan columnName, ulong hash, int precisionBits) + { + try { GetOrCreateColumn(columnName)?.AppendGeohash(hash, precisionBits); } catch { CancelCurrentRow(); throw; } + } + + /// Append a DOUBLE_ARRAY row with the given shape. + public void AppendDoubleArray(ReadOnlySpan columnName, ReadOnlySpan values, ReadOnlySpan shape) + { + try { GetOrCreateColumn(columnName)?.AppendDoubleArray(values, shape); } catch { CancelCurrentRow(); throw; } + } + + /// Append a LONG_ARRAY row with the given shape. + public void AppendLongArray(ReadOnlySpan columnName, ReadOnlySpan values, ReadOnlySpan shape) + { + try { GetOrCreateColumn(columnName)?.AppendLongArray(values, shape); } catch { CancelCurrentRow(); throw; } + } + + /// + /// Drops row data while preserving the table's name and column declarations. Used by the + /// sender to recycle the buffer between batches. + /// + public void Clear() + { + if (HasPendingRow) + { + CancelCurrentRow(); + } + + for (var i = 0; i < _columns.Count; i++) + { + _columns[i].Clear(); + } + + DesignatedTimestampColumn?.Clear(); + + RowCount = 0; + HasPendingRow = false; + _committedColumnCount = _columns.Count; + _designatedSavepoint = null; + + if (_touchedInCurrentRow.Length > 0) + { + Array.Clear(_touchedInCurrentRow, 0, _touchedInCurrentRow.Length); + } + } + + public void TrimToCurrent() + { + for (var i = 0; i < _columns.Count; i++) + { + _columns[i].TrimToCurrent(); + } + + DesignatedTimestampColumn?.TrimToCurrent(); + } + + /// + /// Commit the current row with a TIMESTAMP (microseconds-since-epoch) designated value. + /// + public void At(long timestampMicros) + { + try + { + EnsureCanAppendRow(); + var ts = EnsureDesignatedTimestampColumn(); + _designatedSavepoint ??= ts.Snapshot(); + ts.AppendTimestampMicros(timestampMicros); + FinaliseRow(); + } + catch + { + CancelCurrentRow(); + throw; + } + } + + /// + /// Commit the current row with a TIMESTAMP_NANOS (nanoseconds-since-epoch) designated value. + /// + public void AtNanos(long timestampNanos) + { + try + { + EnsureCanAppendRow(); + var ts = EnsureDesignatedTimestampColumn(); + _designatedSavepoint ??= ts.Snapshot(); + ts.AppendTimestampNanos(timestampNanos); + FinaliseRow(); + } + catch + { + CancelCurrentRow(); + throw; + } + } + + private void EnsureCanAppendRow() + { + if (RowCount >= QwpConstants.MaxRowsPerTable) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"table '{TableName}' exceeds the {QwpConstants.MaxRowsPerTable}-row limit"); + } + } + + /// + /// Look up an existing column or create a new one. + /// + /// + /// The new column is back-filled with nulls for the rows that came before it. + /// + private QwpColumn? GetOrCreateColumn(ReadOnlySpan columnName) + { + if (columnName.Length == 0) + { + throw new IngressError(ErrorCode.InvalidName, "column name must not be empty"); + } + + int idx; +#if NET9_0_OR_GREATER + if (_columnIndexLookup.TryGetValue(columnName, out idx)) + { + SnapshotOnFirstTouch(idx, _columns[idx]); + return MarkTouched(idx) ? _columns[idx] : null; + } +#else + var probeKey = columnName.ToString(); + if (_columnIndex.TryGetValue(probeKey, out idx)) + { + SnapshotOnFirstTouch(idx, _columns[idx]); + return MarkTouched(idx) ? _columns[idx] : null; + } +#endif + + int nameByteCount; + try + { + nameByteCount = QwpStrictUtf8.Encoding.GetByteCount(columnName); + } + catch (EncoderFallbackException ex) + { + throw new IngressError(ErrorCode.InvalidName, + "column name is not valid UTF-8 (lone surrogate)", ex); + } + if (nameByteCount > _maxNameLengthBytes) + { + throw new IngressError(ErrorCode.InvalidName, + $"column name exceeds {_maxNameLengthBytes} UTF-8 bytes (got {nameByteCount})"); + } + + if (_columns.Count >= QwpConstants.MaxColumnsPerTable) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"table '{TableName}' exceeds the {QwpConstants.MaxColumnsPerTable}-column limit"); + } + + var name = columnName.ToString(); + var col = new QwpColumn(name, RowCount); + idx = _columns.Count; + _columns.Add(col); + _columnIndex[name] = idx; + + EnsureTouchedCapacity(idx + 1); + MarkTouched(idx); + return col; + } + + private void SnapshotOnFirstTouch(int index, QwpColumn col) + { + if (index < _touchedInCurrentRow.Length && _touchedInCurrentRow[index]) + { + return; + } + + if (_rowSavepoints.Length <= index) + { + Array.Resize(ref _rowSavepoints, Math.Max(4, index + 1)); + } + _rowSavepoints[index] = col.Snapshot(); + } + + internal void CancelCurrentRow() + { + for (var i = 0; i < _committedColumnCount && i < _touchedInCurrentRow.Length; i++) + { + if (_touchedInCurrentRow[i]) + { + _columns[i].Restore(_rowSavepoints[i]); + } + } + + while (_columns.Count > _committedColumnCount) + { + var last = _columns.Count - 1; + _columnIndex.Remove(_columns[last].Name); + _columns.RemoveAt(last); + } + + if (_designatedCreatedInCurrentRow) + { + DesignatedTimestampColumn = null; + } + else if (_designatedSavepoint.HasValue && DesignatedTimestampColumn is not null) + { + DesignatedTimestampColumn.Restore(_designatedSavepoint.Value); + } + _designatedSavepoint = null; + _designatedCreatedInCurrentRow = false; + + if (_touchedInCurrentRow.Length > 0) + { + Array.Clear(_touchedInCurrentRow, 0, _touchedInCurrentRow.Length); + } + HasPendingRow = false; + } + + /// + /// + /// Lazily creates the designated-timestamp column. The first AppendTimestamp* call + /// on the returned column locks its type code (TIMESTAMP vs. TIMESTAMP_NANOS); subsequent + /// mismatched calls throw via 's type guard. + /// + private QwpColumn EnsureDesignatedTimestampColumn() + { + if (DesignatedTimestampColumn is null) + { + DesignatedTimestampColumn = new QwpColumn(string.Empty, RowCount); + _designatedCreatedInCurrentRow = true; + } + + return DesignatedTimestampColumn; + } + + private void FinaliseRow() + { + for (var i = 0; i < _columns.Count; i++) + { + if (!_touchedInCurrentRow[i]) + { + SnapshotOnFirstTouch(i, _columns[i]); + MarkTouched(i); + _columns[i].AppendNull(); + } + } + + for (var i = 0; i < _columns.Count; i++) + { + _touchedInCurrentRow[i] = false; + } + + RowCount++; + HasPendingRow = false; + _committedColumnCount = _columns.Count; + _designatedSavepoint = null; + _designatedCreatedInCurrentRow = false; + } + + /// Returns false when the column has already been written in this row (ILP first-value-wins). + private bool MarkTouched(int columnIndex) + { + EnsureTouchedCapacity(columnIndex + 1); + if (_touchedInCurrentRow[columnIndex]) + { + return false; + } + _touchedInCurrentRow[columnIndex] = true; + HasPendingRow = true; + return true; + } + + private void EnsureTouchedCapacity(int required) + { + if (_touchedInCurrentRow.Length >= required) + { + return; + } + + var newSize = Math.Max(8, _touchedInCurrentRow.Length); + while (newSize < required) + { + newSize *= 2; + } + + Array.Resize(ref _touchedInCurrentRow, newSize); + } +} diff --git a/src/net-questdb-client/Qwp/QwpVarint.cs b/src/net-questdb-client/Qwp/QwpVarint.cs new file mode 100644 index 00000000..cd65c8bb --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpVarint.cs @@ -0,0 +1,133 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Runtime.CompilerServices; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Unsigned LEB128 varint codec used throughout QWP for variable-length integers. +/// +/// +/// Wire layout: 7 data bits per byte, LSB first; the high bit on every byte except the last +/// signals "more bytes follow". A 64-bit value occupies at most bytes. +/// +/// The QWP spec uses unsigned LEB128 even when representing logically signed values +/// (schema ids, delta-start counters, etc.) — every actual use is non-negative. +/// +internal static class QwpVarint +{ + /// Maximum encoded length of a 64-bit value: ceil(64/7) = 10 bytes. + public const int MaxBytes = 10; + + /// + /// Writes into in LEB128 form. + /// + /// Number of bytes written. + /// If is too small. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int Write(Span dest, ulong value) + { + var i = 0; + while ((value & ~0x7Ful) != 0) + { + if ((uint)i >= (uint)dest.Length) + { + throw new ArgumentException("destination span too small for varint", nameof(dest)); + } + + dest[i++] = (byte)((value & 0x7F) | 0x80); + value >>= 7; + } + + if ((uint)i >= (uint)dest.Length) + { + throw new ArgumentException("destination span too small for varint", nameof(dest)); + } + + dest[i++] = (byte)value; + return i; + } + + /// + /// Reads an LEB128-encoded value from . + /// + /// Buffer to read from. The caller is expected to have ensured at least one byte is available. + /// Number of bytes consumed. + /// The decoded value. + /// + /// If the encoding runs past bytes, or the input ends mid-value. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Read(ReadOnlySpan src, out int bytesRead) + { + ulong result = 0; + var shift = 0; + + for (var i = 0; i < MaxBytes; i++) + { + if (i >= src.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "varint truncated"); + } + + var b = src[i]; + // 10th byte may only carry bit 63 (0x01) and must terminate. + if (i == MaxBytes - 1 && (b & 0xFE) != 0) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "varint out of range"); + } + + result |= (ulong)(b & 0x7F) << shift; + + if ((b & 0x80) == 0) + { + bytesRead = i + 1; + return result; + } + + shift += 7; + } + + throw new IngressError(ErrorCode.ProtocolVersionError, "varint exceeds 10 bytes"); + } + + /// + /// Returns the number of bytes would emit for . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetByteCount(ulong value) + { + var n = 1; + while ((value & ~0x7Ful) != 0) + { + value >>= 7; + n++; + } + + return n; + } +} diff --git a/src/net-questdb-client/Qwp/QwpWebSocketTransport.cs b/src/net-questdb-client/Qwp/QwpWebSocketTransport.cs new file mode 100644 index 00000000..258f6cd8 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpWebSocketTransport.cs @@ -0,0 +1,522 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Buffers.Binary; +using System.Globalization; +using System.Net; +using System.Net.Security; +using System.Net.WebSockets; +using System.Reflection; +using QuestDB.Enums; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Thin wrapper over that handles QWP-specific upgrade +/// headers, version negotiation, single-frame binary I/O, optional dump-mode capture, and +/// graceful close. +/// +/// +/// Why net7.0+ only. The version-negotiation handshake reads the server's +/// X-QWP-Version response header via , +/// which was added in .NET 7. Older targets get HTTP / TCP transports only. +/// +/// KeepAliveInterval: zero unless durable-ack is requested. The cursor-engine spec +/// would prefer PINGs gated on a non-empty pendingDurable queue as well, but +/// sets the cadence once at construction and exposes no public +/// ping-send API, so the gate is approximated by "durable-ack negotiated + non-zero interval". +/// Net effect on an idle durable-ack sender: a few unnecessary PING frames; server tolerates them. +/// +/// Dump format: when is set, the +/// transport tees both directions of binary traffic to that stream as +/// [direction byte 'S'/'R'][uint32 LE length][payload] records. The format is internal +/// and may change between client versions; useful for tests and bug reports. +/// +internal sealed class QwpWebSocketTransport : IQwpCursorTransport +{ + private const int DumpHeaderSize = 5; + private static readonly string DefaultClientId = BuildDefaultClientId(); + + private readonly QwpWebSocketTransportOptions _options; + private readonly ClientWebSocket _client; + private readonly object _dumpLock = new(); + + private int _disposed; + private int _negotiatedVersion; + private int _negotiatedMaxBatchSize; + + /// Constructs a transport. must be called before any I/O. + public QwpWebSocketTransport(QwpWebSocketTransportOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (options.Uri is null) + { + throw new ArgumentException("Uri is required", nameof(options)); + } + + _options = options; + Endpoint = (options.Uri.Host, options.Uri.Port); + _client = new ClientWebSocket(); + + var ws = _client.Options; + ws.KeepAliveInterval = options.KeepAliveInterval; + ws.CollectHttpResponseDetails = true; // expose response headers for X-QWP-Version negotiation + ws.Proxy = options.Proxy; + ws.SetRequestHeader(QwpConstants.HeaderMaxVersion, options.ClientMaxVersion.ToString()); + ws.SetRequestHeader(QwpConstants.HeaderClientId, options.ClientId ?? DefaultClientId); + + if (!string.IsNullOrEmpty(options.AuthorizationHeader)) + { + ws.SetRequestHeader("Authorization", options.AuthorizationHeader); + } + + if (options.RequestDurableAck) + { + ws.SetRequestHeader(QwpConstants.HeaderRequestDurableAck, "true"); + } + + if (options.RemoteCertificateValidationCallback is not null) + { + ws.RemoteCertificateValidationCallback = options.RemoteCertificateValidationCallback; + } + + if (options.ExtraRequestHeaders is { } extra) + { + foreach (var kv in extra) + { + ws.SetRequestHeader(kv.Key, kv.Value); + } + } + } + + /// True once the upgrade has succeeded and the WebSocket is open. + public bool IsConnected => _client.State == WebSocketState.Open; + + /// + /// QWP version chosen by the server during the upgrade. 0 until + /// has run; always 1 in v1. + /// + public int NegotiatedVersion => _negotiatedVersion; + + /// + /// Server-selected X-QWP-Content-Encoding from the upgrade response (e.g. + /// "zstd;level=3"); null when omitted (= raw) or before . + /// + public string? NegotiatedContentEncoding { get; private set; } + + /// + public int NegotiatedMaxBatchSize => _negotiatedMaxBatchSize; + + /// + public string? NegotiatedZone { get; private set; } + + /// + public (string Host, int Port)? Endpoint { get; } + + /// + /// Opens the TCP/TLS connection, performs the WebSocket upgrade, and validates that the + /// server selected a version this client speaks. + /// + public async Task ConnectAsync(CancellationToken ct = default) + { + ThrowIfDisposed(); + + try + { + await _client.ConnectAsync(_options.Uri!, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + var status = (int)_client.HttpStatusCode; + if (status is 401 or 403) + { + throw new QwpAuthFailedException(status, _options.Uri, ex); + } + + if (status == 421) + { + var role = ReadOptionalHeader(QwpConstants.HeaderQuestDbRole); + if (!string.IsNullOrEmpty(role)) + { + var zone = ReadOptionalHeader(QwpConstants.HeaderQuestDbZone); + throw new QwpIngressRoleRejectedException(role!, _options.Uri!, zone, ex); + } + } + + throw new IngressError(ErrorCode.SocketError, $"failed to connect to {_options.Uri}", ex); + } + + _negotiatedVersion = ReadNegotiatedVersion(); + if (_negotiatedVersion != QwpConstants.SupportedVersion) + { + await TryCloseAsync(WebSocketCloseStatus.ProtocolError, "unsupported QWP version", ct) + .ConfigureAwait(false); + throw new IngressError( + ErrorCode.ProtocolVersionError, + $"server negotiated QWP version {_negotiatedVersion}; this client only speaks v{QwpConstants.SupportedVersion}"); + } + + if (_options.RequestDurableAck) + { + var ack = ReadOptionalHeader(QwpConstants.HeaderDurableAck); + if (!string.Equals(ack, "enabled", StringComparison.OrdinalIgnoreCase)) + { + await TryCloseAsync(WebSocketCloseStatus.PolicyViolation, + "durable-ack opt-in not honoured by server", ct).ConfigureAwait(false); + throw new IngressError( + ErrorCode.DurableAckNotSupported, + "client requested durable-ack but server did not echo `X-QWP-Durable-Ack: enabled` " + + "on the upgrade response (primary replication not configured on this engine)"); + } + } + + NegotiatedContentEncoding = ReadOptionalHeader(QwpConstants.HeaderContentEncoding); + + var maxBatch = ReadOptionalHeader(QwpConstants.HeaderMaxBatchSize); + _negotiatedMaxBatchSize = maxBatch is not null + && int.TryParse(maxBatch, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + && parsed > 0 + ? parsed + : 0; + + NegotiatedZone = ReadOptionalHeader(QwpConstants.HeaderQuestDbZone); + } + + private string? ReadOptionalHeader(string name) + { + var headers = _client.HttpResponseHeaders; + if (headers is null || !headers.TryGetValue(name, out var values)) return null; + foreach (var v in values) + { + if (string.IsNullOrEmpty(v)) continue; + var trimmed = v.Trim(); + if (trimmed.Length > 0) return trimmed; + } + return null; + } + + /// Sends one QWP frame as a single WebSocket BINARY message. + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken ct = default) + { + ThrowIfDisposed(); + EnsureOpen(); + + DumpFrame((byte)'S', data.Span); + await _client.SendAsync(data, WebSocketMessageType.Binary, endOfMessage: true, ct) + .ConfigureAwait(false); + } + + /// + /// Receives one QWP response frame, aggregating fragments until EndOfMessage. + /// + /// Caller-owned buffer; must be large enough for the entire message. + /// Cancellation token; cancellation aborts the in-progress receive. + /// Number of bytes written into . + /// + /// If the message exceeds , the server closes the connection, + /// or the message is not a binary frame. + /// + public async Task ReceiveFrameAsync(Memory destination, CancellationToken ct = default) + { + ThrowIfDisposed(); + EnsureOpen(); + + var totalRead = 0; + while (true) + { + var slice = destination.Slice(totalRead); + if (slice.Length == 0) + { + throw new IngressError( + ErrorCode.SocketError, + $"incoming WebSocket frame exceeds the {destination.Length}-byte receive buffer; " + + "decrease batch size or increase the buffer"); + } + + var result = await _client.ReceiveAsync(slice, ct).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + throw MapCloseToException(); + } + + if (result.MessageType != WebSocketMessageType.Binary) + { + throw new IngressError( + ErrorCode.ProtocolVersionError, + $"unexpected WebSocket message type {result.MessageType}; QWP uses binary frames"); + } + + totalRead += result.Count; + + if (result.EndOfMessage) + { + DumpFrame((byte)'R', destination.Span.Slice(0, totalRead)); + return totalRead; + } + } + } + + /// + /// Like but doubles + /// when the incoming frame would otherwise overflow, up to + /// . Returns the (possibly grown) buffer along with the byte + /// count. Frames exceeding the cap raise a SocketError. + /// + public async Task<(int Read, byte[] Buffer)> ReceiveFrameAsync( + byte[] initial, + int maxBytes, + CancellationToken ct = default) + { + ThrowIfDisposed(); + EnsureOpen(); + + var buffer = initial; + var totalRead = 0; + while (true) + { + if (totalRead == buffer.Length) + { + if (buffer.Length >= maxBytes) + { + throw new IngressError( + ErrorCode.SocketError, + $"incoming WebSocket frame exceeds the {maxBytes}-byte receive cap"); + } + + var newSize = Math.Min(maxBytes, Math.Max(buffer.Length * 2, totalRead + 1)); + Array.Resize(ref buffer, newSize); + } + + var slice = buffer.AsMemory(totalRead); + var result = await _client.ReceiveAsync(slice, ct).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + throw MapCloseToException(); + } + + if (result.MessageType != WebSocketMessageType.Binary) + { + throw new IngressError( + ErrorCode.ProtocolVersionError, + $"unexpected WebSocket message type {result.MessageType}; QWP uses binary frames"); + } + + totalRead += result.Count; + + if (result.EndOfMessage) + { + DumpFrame((byte)'R', buffer.AsSpan(0, totalRead)); + return (totalRead, buffer); + } + } + } + + // Protocol-violation close codes are terminal: replaying the same bytes against a fresh + // connection would just re-trigger the close. Everything else routes to the reconnect loop. + private IngressError MapCloseToException() + { + var status = _client.CloseStatus ?? WebSocketCloseStatus.Empty; + var reason = _client.CloseStatusDescription; + if (QwpProtocolViolationException.IsProtocolViolationCode(status)) + { + return new QwpProtocolViolationException(status, reason); + } + return new IngressError( + ErrorCode.SocketError, + $"server closed the WebSocket: {status} {reason}"); + } + + /// + /// Sends a graceful WebSocket CLOSE frame and waits for the server's acknowledgement. + /// Idempotent and exception-tolerant: a transport in any non-closed state can call this. + /// + public async Task CloseAsync( + WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, + string? description = null, + CancellationToken ct = default) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + await TryCloseAsync(status, description, ct).ConfigureAwait(false); + } + + Task IQwpCursorTransport.CloseAsync(CancellationToken cancellationToken) => + CloseAsync(ct: cancellationToken); + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + SfCleanup.Dispose(_client); + } + + private async Task TryCloseAsync(WebSocketCloseStatus status, string? description, CancellationToken ct) + { + // Only attempt a CLOSE when the channel is still in a state that accepts one. + var state = _client.State; + if (state is not WebSocketState.Open and not WebSocketState.CloseReceived) + { + return; + } + + try + { + await _client.CloseAsync(status, description, ct).ConfigureAwait(false); + } + catch + { + // Best-effort close; we already have the user's primary error in flight. + } + } + + private int ReadNegotiatedVersion() + { + var headers = _client.HttpResponseHeaders; + if (headers is null || !headers.TryGetValue(QwpConstants.HeaderVersion, out var values)) + { + return 1; + } + + foreach (var value in values) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)) + { + return v; + } + } + + throw new IngressError( + ErrorCode.ProtocolVersionError, + $"server returned invalid {QwpConstants.HeaderVersion} header value"); + } + + private void DumpFrame(byte direction, ReadOnlySpan bytes) + { + var dump = _options.DumpStream; + if (dump is null) + { + return; + } + + Span header = stackalloc byte[DumpHeaderSize]; + header[0] = direction; + BinaryPrimitives.WriteInt32LittleEndian(header.Slice(1, 4), bytes.Length); + + // SendBinaryAsync and ReceiveFrameAsync run concurrently; serialise so records don't tear. + lock (_dumpLock) + { + dump.Write(header); + dump.Write(bytes); + } + } + + private void EnsureOpen() + { + if (_client.State != WebSocketState.Open) + { + throw new IngressError( + ErrorCode.SocketError, + $"WebSocket is not open (state={_client.State}); call ConnectAsync first"); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(QwpWebSocketTransport)); + } + } + + private static string BuildDefaultClientId() + { + var version = typeof(QwpWebSocketTransport).Assembly + .GetCustomAttribute() + ?.InformationalVersion + ?? typeof(QwpWebSocketTransport).Assembly.GetName().Version?.ToString() + ?? "unknown"; + return $"dotnet/{version}"; + } +} + +/// Construction options for . +internal sealed class QwpWebSocketTransportOptions +{ + /// The endpoint URI, including scheme (ws or wss), host, port, and path. + public Uri? Uri { get; init; } + + /// Optional Authorization header (e.g. "Basic abc..." or "Bearer xyz..."). + public string? AuthorizationHeader { get; init; } + + /// Whether to opt in to STATUS_DURABLE_ACK frames. Maps to the upgrade header. + public bool RequestDurableAck { get; init; } + + /// + /// WebSocket-level keepalive PING cadence. Zero disables. Per spec, the ingest sender sets + /// this only when request_durable_ack=on so the server flushes pending durable-ack + /// frames on each ping recv event. + /// + public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.Zero; + + /// Optional dump-mode stream; the transport tees both directions of binary traffic here. + public Stream? DumpStream { get; init; } + + /// Maximum QWP version this client speaks. The wire pins ingest to 1; exposing the knob is for forward-compat tests. + public int ClientMaxVersion { get; init; } = QwpConstants.SupportedVersion; + + /// Free-form client identifier used for the X-QWP-Client-Id header. + public string? ClientId { get; init; } + + /// Optional callback for TLS certificate validation; bypassed when null. + public RemoteCertificateValidationCallback? RemoteCertificateValidationCallback { get; init; } + + /// Extra HTTP request headers to set on the WebSocket upgrade. + public IReadOnlyDictionary? ExtraRequestHeaders { get; init; } + + /// + /// Proxy override for the underlying . null = no proxy + /// (default; long-lived WS rarely survives HTTP proxies). Set to + /// for system proxy, or a fresh for an explicit URI. + /// + public IWebProxy? Proxy { get; init; } +} + +#endif diff --git a/src/net-questdb-client/Qwp/Sf/IQwpCursorTransport.cs b/src/net-questdb-client/Qwp/Sf/IQwpCursorTransport.cs new file mode 100644 index 00000000..900b8668 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/IQwpCursorTransport.cs @@ -0,0 +1,90 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// Minimal wire-level abstraction the SF cursor engine depends on. Every reconnect attempt +/// uses a fresh instance. +/// +/// +/// Implementations must surface auth/upgrade rejections (HTTP 401/403, non-101 upgrade) by +/// throwing with from +/// — the engine treats these as immediate terminal failures and +/// does not retry. All other transport failures are surfaced as transient and retried under +/// the configured reconnect budget. +/// +internal interface IQwpCursorTransport : IDisposable +{ + /// + /// The endpoint this transport will (or did) connect to. null when the transport + /// is not bound to a specific endpoint (e.g. test stubs). + /// + (string Host, int Port)? Endpoint { get; } + + /// + /// Server-advertised hard cap on QWP ingest payload bytes from the upgrade response; + /// 0 when the server did not advertise it. Valid only after . + /// + int NegotiatedMaxBatchSize => 0; + + /// + /// Server-advertised availability zone for this host from the upgrade response + /// (X-QuestDB-Zone); null when the server did not advertise it. Valid only + /// after . Feeds same-zone-vs-other-zone failover tiering. + /// + string? NegotiatedZone => null; + + /// Connects to the server and completes the QWP upgrade. + Task ConnectAsync(CancellationToken cancellationToken); + + /// Sends a single QWP request frame. + Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken); + + /// Reads the next QWP response frame into . + /// The number of bytes written. + Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken); + + /// + /// Reads the next QWP response frame into , growing it + /// up to when the frame would otherwise overflow. A valid + /// ACK can exceed any fixed buffer (one per-table entry per touched table), so the cursor + /// receive path must use this growable form rather than treating overflow as a transient + /// socket error. The default implementation falls back to the fixed overload for stubs + /// that pre-size their buffer. + /// + /// The byte count along with the buffer actually used (== unless grown). + async Task<(int Read, byte[] Buffer)> ReceiveFrameAsync( + byte[] initialBuffer, int maxBytes, CancellationToken cancellationToken) + { + var read = await ReceiveFrameAsync(initialBuffer.AsMemory(), cancellationToken).ConfigureAwait(false); + return (read, initialBuffer); + } + + /// Sends a graceful close. Must tolerate being called from any state. + Task CloseAsync(CancellationToken cancellationToken); +} diff --git a/src/net-questdb-client/Qwp/Sf/IQwpSegment.cs b/src/net-questdb-client/Qwp/Sf/IQwpSegment.cs new file mode 100644 index 00000000..f2c6d57f --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/IQwpSegment.cs @@ -0,0 +1,57 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Sf; + +/// +/// Storage backing for a single segment of the cursor send engine's ring. Two implementations: +/// (file-backed, persistent across restarts when sf_dir is set) +/// and (malloc-backed, RAM only, used when sf_dir is null). +/// +/// +/// Dispose may throw. A file-backed segment flushes on +/// and rethrows a flush — SF's data-on-disk promise depends +/// on observing msync failures rather than swallowing them. Callers must wrap Dispose +/// (e.g. via ) rather than relying on it being exception-free. +/// +internal interface IQwpSegment : IDisposable +{ + string Path { get; } + long Capacity { get; } + long BaseFsn { get; } + long WritePosition { get; } + long NextFsn { get; } + long EnvelopeCount { get; } + bool IsSealed { get; } + + bool TryAppend(ReadOnlySpan frame); + + int TryReadFrame(long offset, Span destination, out long envelopeFsn); + + long? OffsetOfEnvelope(long envelopeIndex); + + void Seal(); + + void Flush(); +} diff --git a/src/net-questdb-client/Qwp/Sf/IQwpSlotDrainer.cs b/src/net-questdb-client/Qwp/Sf/IQwpSlotDrainer.cs new file mode 100644 index 00000000..5d3fc5b0 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/IQwpSlotDrainer.cs @@ -0,0 +1,42 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Sf; + +/// +/// Drains all unacked envelopes from a single slot directory over a dedicated WebSocket +/// connection. Implemented by the cursor engine in drain mode. +/// +/// +/// The drainer reads the slot's segment ring from disk, replays envelopes to the server, +/// advances the ack watermark, and trims segments as they're fully acked. Returns once +/// OldestFsn == NextFsn (slot fully drained) or throws on terminal failure. +/// +/// The pool that invokes this is responsible for the slot lock — implementations must NOT +/// dispose the lock themselves. +/// +internal interface IQwpSlotDrainer +{ + Task DrainAsync(string slotDirectory, CancellationToken cancellationToken); +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpAckWatermark.cs b/src/net-questdb-client/Qwp/Sf/QwpAckWatermark.cs new file mode 100644 index 00000000..f65b7707 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpAckWatermark.cs @@ -0,0 +1,168 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.IO.MemoryMappedFiles; + +namespace QuestDB.Qwp.Sf; + +/// +/// Persisted high-water mark for the durably-acknowledged FSN. Lives at +/// <slot>/.ack-watermark. Read at engine startup to refine the +/// segment-derived ackedFsn seed across process restarts, eliminating +/// re-replay of frames inside the lowest surviving sealed segment that the +/// previous sender already received durable-acks for. +/// +internal sealed class QwpAckWatermark : IDisposable +{ + public const string FileName = ".ack-watermark"; + public const int FileSize = 16; + + /// Returned by when the file has no usable watermark. + public const long Invalid = long.MinValue; + + private const uint MagicValue = 0x31574B41u; + private const long MagicOffset = 0; + // Crash-atomicity relies on the 8-byte-aligned FSN residing within a single disk sector, so a + // host crash mid-write either leaves the old value intact or commits the new one — never a tear. + private const long FsnOffset = 8; + + private readonly MemoryMappedFile _mmf; + private readonly MemoryMappedViewAccessor _view; + private bool _magicWritten; + private bool _disposed; + + private QwpAckWatermark(MemoryMappedFile mmf, MemoryMappedViewAccessor view, bool magicAlreadyWritten) + { + _mmf = mmf; + _view = view; + _magicWritten = magicAlreadyWritten; + } + + /// + /// Opens (creating if absent) the watermark file in . + /// Returns null on any setup failure; the caller falls back to the + /// no-watermark behaviour. + /// + public static QwpAckWatermark? Open(string slotDirectory) + { + ArgumentNullException.ThrowIfNull(slotDirectory); + var path = Path.Combine(slotDirectory, FileName); + FileStream? fs = null; + MemoryMappedFile? mmf = null; + MemoryMappedViewAccessor? view = null; + try + { + long existing = File.Exists(path) ? new FileInfo(path).Length : -1L; + if (existing == FileSize) + { + fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + } + else if (existing < 0) + { + fs = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read); + fs.SetLength(FileSize); + } + else + { + // Preserve a wrong-sized file for diagnosis instead of truncating it. + return null; + } + + mmf = MemoryMappedFile.CreateFromFile( + fs, mapName: null, FileSize, MemoryMappedFileAccess.ReadWrite, + HandleInheritability.None, leaveOpen: false); + // CreateFromFile took ownership of fs when leaveOpen=false. + fs = null; + view = mmf.CreateViewAccessor(0, FileSize, MemoryMappedFileAccess.ReadWrite); + var magic = view.ReadUInt32(MagicOffset); + return new QwpAckWatermark(mmf, view, magic == MagicValue); + } + catch + { + view?.Dispose(); + mmf?.Dispose(); + fs?.Dispose(); + return null; + } + } + + /// Best-effort unlink of a stale watermark file. + public static void RemoveOrphan(string slotDirectory) + { + ArgumentNullException.ThrowIfNull(slotDirectory); + try + { + File.Delete(Path.Combine(slotDirectory, FileName)); + } + catch + { + } + } + + /// + /// Single-load read of the current FSN. Returns when + /// the file has never been written (magic field is zero). + /// + public long Read() + { + if (_disposed) return Invalid; + var magic = _view.ReadUInt32(MagicOffset); + if (magic != MagicValue) return Invalid; + return _view.ReadInt64(FsnOffset); + } + + /// + /// Atomically updates the persisted FSN. First write of a fresh file also + /// stamps the magic so the next session's can distinguish + /// "valid watermark" from "freshly created file". + /// + public void Write(long fsn) + { + if (_disposed) return; + _view.Write(FsnOffset, fsn); + if (!_magicWritten) + { + _view.Write(MagicOffset, MagicValue); + _magicWritten = true; + } + } + + /// + /// Forces the mapped view to disk so a host crash cannot lose or tear the + /// last . Best-effort: a no-op once disposed. + /// + public void Flush() + { + if (_disposed) return; + _view.Flush(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _view.Dispose(); + _mmf.Dispose(); + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainer.cs b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainer.cs new file mode 100644 index 00000000..0d272ee3 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainer.cs @@ -0,0 +1,154 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; + +namespace QuestDB.Qwp.Sf; + +/// +/// SF orphan-slot drainer that reuses the live in +/// "no-lock" mode. Inherits the engine's reconnect + replay machinery for free. +/// +/// +/// The drainer is invoked by after +/// has claimed the slot lock. The pool keeps the lock alive +/// for the drain duration; the engine constructed here is given slotLock=null so it +/// does not touch the lock on dispose. +/// +/// Drain sequence: +/// +/// open the segment ring on the slot directory; +/// if empty, return immediately (engine.Dispose still unlinks any stragglers); +/// start the engine and call with the +/// configured drain timeout — this returns once the server has acked every envelope; +/// dispose the engine, which trims residual .sfa files thanks to its +/// full-drain cleanup (see ). +/// +/// +internal sealed class QwpBackgroundDrainer : IQwpSlotDrainer +{ + private readonly Func _contextBuilder; + private readonly QwpReconnectPolicy _reconnectPolicy; + private readonly long _segmentCapacity; + private readonly TimeSpan _drainTimeout; + private readonly bool _durableAckMode; + + // Per-drain context isolates host-health state across concurrent drains; the foreground engine + // and each pooled drainer task get their own tracker so a BeginRound by one does not clear the + // round-attempted flags of another. + public QwpBackgroundDrainer( + Func contextBuilder, + QwpReconnectPolicy reconnectPolicy, + long segmentCapacity, + TimeSpan drainTimeout, + bool durableAckMode = false) + { + ArgumentNullException.ThrowIfNull(contextBuilder); + ArgumentNullException.ThrowIfNull(reconnectPolicy); + if (segmentCapacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(segmentCapacity), "must be positive"); + } + + if (drainTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(drainTimeout), "must be positive"); + } + + _contextBuilder = contextBuilder; + _reconnectPolicy = reconnectPolicy; + _segmentCapacity = segmentCapacity; + _drainTimeout = drainTimeout; + _durableAckMode = durableAckMode; + } + + public QwpBackgroundDrainer( + Func transportFactory, + QwpReconnectPolicy reconnectPolicy, + long segmentCapacity, + TimeSpan drainTimeout, + Func? skipBackoffPredicate = null, + bool durableAckMode = false) + : this( + () => new DrainContext(transportFactory, skipBackoffPredicate), + reconnectPolicy, + segmentCapacity, + drainTimeout, + durableAckMode) + { + ArgumentNullException.ThrowIfNull(transportFactory); + } + + public async Task DrainAsync(string slotDirectory, CancellationToken cancellationToken) + { + var ctx = _contextBuilder(); + var ring = QwpSegmentRing.Open(slotDirectory, segmentCapacity: _segmentCapacity); + QwpAckWatermark? watermark = null; + QwpCursorSendEngine? engine = null; + try + { + if (ring.NextFsn == 0) + { + QwpAckWatermark.RemoveOrphan(slotDirectory); + } + watermark = QwpAckWatermark.Open(slotDirectory); + + // Construct the engine even when the ring is empty so engine.Dispose's full-drain + // branch still unlinks residual sf-*.sfa files. A slot with empty .sfa survivors + // would otherwise be re-adopted by every subsequent scan and never make progress. + engine = new QwpCursorSendEngine( + slotLock: null, + ring, + ctx.TransportFactory, + _reconnectPolicy, + appendDeadline: TimeSpan.FromSeconds(30), + initialConnectMode: InitialConnectMode.off, + skipBackoffPredicate: ctx.SkipBackoffPredicate, + durableAckMode: _durableAckMode, + ackWatermark: watermark); + + if (ring.NextFsn > ring.OldestFsn) + { + engine.Start(); + await engine.FlushAsync(_drainTimeout, cancellationToken).ConfigureAwait(false); + } + } + finally + { + if (engine is not null) + { + engine.Dispose(); + } + else + { + ring.Dispose(); + if (watermark is not null) watermark.Dispose(); + } + } + } +} + +internal readonly record struct DrainContext( + Func TransportFactory, + Func? SkipBackoffPredicate); diff --git a/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainerPool.cs b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainerPool.cs new file mode 100644 index 00000000..85a9e1c5 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainerPool.cs @@ -0,0 +1,334 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// Bounded concurrent worker pool that drains sibling slot directories adopted by +/// . +/// +/// +/// Each hands a held to the pool. A +/// caps the number of drains running concurrently +/// (max_background_drainers, default 4). For each task: +/// +/// drain runs through the configured ; +/// on success, the slot lock is disposed — the slot is empty so it may be reclaimed +/// by any sender; +/// on a deterministic terminal failure (auth reject, protocol violation, +/// wire halt, corrupt segment files), a .failed sentinel is written before the +/// lock is released, so subsequent sweeps will skip the +/// slot and the user can manually inspect it; +/// on a transient failure (drain timeout, or reconnect-budget exhaustion from +/// a temporary outage) the slot lock is disposed without a sentinel, so the slot is +/// re-adopted on a later sweep — a brief outage must not permanently abandon recoverable +/// un-acked frames; +/// on cancellation, the slot lock is disposed without dropping a sentinel — drain +/// will be retried on the next sender startup. +/// +/// +internal sealed class QwpBackgroundDrainerPool : IDisposable +{ + private const string FailedSentinel = ".failed"; + + // Orphan-drainer shutdown grace, deliberately decoupled from close_flush_timeout (mirrors the + // Java client's BackgroundDrainerPool): a short graceful drain window, then a brief stop-grace + // for in-flight drainers to release their slot locks and exit before we abandon them. Scaling + // this to close_flush_timeout would let a wedged drainer add the full flush budget to Dispose(). + private static readonly TimeSpan GracefulDrainWait = TimeSpan.FromMilliseconds(2500); + private static readonly TimeSpan StopGraceWait = TimeSpan.FromMilliseconds(500); + + private readonly IQwpSlotDrainer _drainer; + private readonly SemaphoreSlim _slots; + private readonly object _trackingLock = new(); + private readonly List _runningTasks = new(); + private readonly HashSet _liveLocks = new(); + // Per-Enqueue linked CTS keyed by its task; pruned alongside _runningTasks so the non-wedge + // path never leaks. The task's own finally also disposes its CTS (idempotent). + private readonly Dictionary _linkedCtsByTask = new(); + private readonly CancellationTokenSource _shutdownCts = new(); + private readonly TimeSpan _shutdownWait; + private bool _disposed; + + public QwpBackgroundDrainerPool(int maxConcurrent, IQwpSlotDrainer drainer, TimeSpan? shutdownWait = null) + { + try + { + if (maxConcurrent <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxConcurrent), "must be ≥ 1"); + } + + _drainer = drainer ?? throw new ArgumentNullException(nameof(drainer)); + _slots = new SemaphoreSlim(maxConcurrent, maxConcurrent); + _shutdownWait = shutdownWait ?? GracefulDrainWait; + } + catch + { + _shutdownCts.Dispose(); + throw; + } + } + + /// Submits a drain. Lock ownership transfers to the pool. + public void Enqueue(QwpSlotLock slotLock, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(slotLock); + + // Atomic against Dispose: hold _trackingLock for both the disposal check and the task + // registration so a Task created here can never escape Dispose's snapshot. + CancellationTokenSource linked; + Task task; + lock (_trackingLock) + { + EnsureNotDisposed(); + linked = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token, cancellationToken); + _liveLocks.Add(slotLock); + task = Task.Run(async () => + { + try + { + await RunDrainAsync(slotLock, linked.Token).ConfigureAwait(false); + } + finally + { + linked.Dispose(); + } + }); + _runningTasks.Add(task); + _linkedCtsByTask[task] = linked; + } + + // Schedule a continuation that prunes the completed task from the tracking list, bounded + // memory regardless of how many slots get drained over a sender's lifetime. + _ = task.ContinueWith(static (t, state) => + { + var self = (QwpBackgroundDrainerPool)state!; + lock (self._trackingLock) + { + self._runningTasks.Remove(t); + self._linkedCtsByTask.Remove(t); + } + }, this, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + /// Awaits all currently-enqueued drains. Subsequent calls are independent. + public Task WaitForAllAsync() + { + Task[] snapshot; + lock (_trackingLock) + { + snapshot = _runningTasks.ToArray(); + } + + return Task.WhenAll(snapshot); + } + + /// + public void Dispose() + { + Task[] snapshot; + lock (_trackingLock) + { + if (_disposed) return; + _disposed = true; + snapshot = _runningTasks.ToArray(); + } + + var allJoined = snapshot.Length == 0; + if (snapshot.Length > 0) + { + var joinTask = Task.WhenAll(snapshot); + try + { + allJoined = joinTask.Wait(_shutdownWait); + } + catch (Exception) + { + } + + SfCleanup.Run(() => _shutdownCts.Cancel()); + + try + { + allJoined = joinTask.Wait(StopGraceWait); + } + catch (Exception) + { + } + } + + // Release slot locks even on join timeout, otherwise a wedged drainer keeps the .lock file + // held and blocks future senders from claiming the same sender_id. QwpSlotLock.Dispose is + // idempotent so the drainer's own finally can still run safely. + QwpSlotLock[] stragglers; + lock (_trackingLock) + { + stragglers = _liveLocks.ToArray(); + _liveLocks.Clear(); + } + foreach (var l in stragglers) + { + SfCleanup.Dispose(l); + } + + // Non-wedge path: every task has finished, so dispose all linked CTSs (re-dispose is a + // no-op). On the wedge path they are left to leak with _shutdownCts/_slots below, since a + // still-running task may touch its CTS. + CancellationTokenSource[] linkedCtsSnapshot; + lock (_trackingLock) + { + linkedCtsSnapshot = _linkedCtsByTask.Values.ToArray(); + if (allJoined) + { + _linkedCtsByTask.Clear(); + } + } + if (allJoined) + { + foreach (var cts in linkedCtsSnapshot) + { + SfCleanup.Dispose(cts); + } + } + + // On wedge, leak _shutdownCts, _slots, and the still-running tasks' linked CTSs so a + // still-running drainer can't fault on a disposed dependency. + if (allJoined) + { + SfCleanup.Dispose(_shutdownCts); + _slots.Dispose(); + } + } + + private async Task RunDrainAsync(QwpSlotLock slotLock, CancellationToken cancellationToken) + { + try + { + try + { + await _slots.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + // Pool's _shutdownCts already disposed; treat as cancellation. + return; + } + + try + { + await _drainer.DrainAsync(slotLock.SlotDirectory, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (ObjectDisposedException) + { + // Late teardown of a CTS / drainer dependency is shutdown noise, not a slot failure. + } + catch (Exception ex) when (IsRetryableDrainFault(ex)) + { + // Transient: a slow/unreachable server or a backlog larger than the drain window. + // Leave NO sentinel so QwpOrphanScanner re-adopts the slot on a later sweep; a brief + // outage must not permanently abandon the crashed sibling's recoverable frames. + System.Diagnostics.Trace.TraceWarning( + $"QWP orphan drain of `{slotLock.SlotDirectory}` did not complete (transient: " + + $"{ex.GetType().Name}); slot left for re-adoption."); + } + catch (Exception ex) + { + TryDropFailedSentinel(slotLock, ex); + } + finally + { + try + { + _slots.Release(); + } + catch (ObjectDisposedException) + { + } + catch (SemaphoreFullException) + { + } + } + } + finally + { + lock (_trackingLock) + { + _liveLocks.Remove(slotLock); + } + slotLock.Dispose(); + } + } + + private static bool IsRetryableDrainFault(Exception ex) + { + if (ex is OperationCanceledException or TimeoutException) + { + return true; + } + + if (ex is IngressError { InnerException: { } cause }) + { + return !QwpCursorSendEngine.IsTerminalServerError(cause); + } + + return false; + } + + private const int FailedSentinelMaxBytes = 4096; + + private static void TryDropFailedSentinel(QwpSlotLock slotLock, Exception ex) + { + var content = ex.ToString(); + if (content.Length > FailedSentinelMaxBytes) + { + content = content.Substring(0, FailedSentinelMaxBytes) + "\n... [truncated]"; + } + slotLock.TryRunUnderLock(dir => + { + try + { + File.WriteAllText(Path.Combine(dir, FailedSentinel), content); + } + catch (Exception) + { + // Best-effort sentinel; swallow any I/O failure rather than mask the real drain error. + } + }); + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpBackgroundDrainerPool)); + } + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpCrc32C.cs b/src/net-questdb-client/Qwp/Sf/QwpCrc32C.cs new file mode 100644 index 00000000..b74f2549 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpCrc32C.cs @@ -0,0 +1,158 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Runtime.CompilerServices; + +namespace QuestDB.Qwp.Sf; + +/// +/// CRC32C (Castagnoli) checksum, software slice-by-8 implementation. +/// +/// +/// Polynomial 0x1EDC6F41 (reflected: 0x82F63B78); seed and final XOR are +/// 0xFFFFFFFF; input and output are reflected. +/// +/// Used by the store-and-forward segment envelope ([u32 crc | u32 frame_len | frame bytes]). +/// The implementation deliberately avoids System.IO.Hashing.Crc32C and the SSE 4.2 / arm64 +/// intrinsics so behaviour is identical across runtime versions and CPUs. +/// +/// Validation: matches the standard test vector CRC32C("123456789") == 0xE3069283 +/// bit-for-bit across runtime versions and CPUs. +/// +internal static class QwpCrc32C +{ + private const uint Polynomial = 0x82F63B78u; + + private static readonly uint[][] Tables = BuildSliceBy8Tables(); + + static QwpCrc32C() + { + // Standard test vector — fail-fast on table-build / runtime regressions before any peer + // verification depends on bit-identical output. + ReadOnlySpan probe = "123456789"u8; + var actual = Compute(probe); + if (actual != 0xE3069283u) + { + throw new InvalidOperationException( + $"QwpCrc32C self-test failed: expected 0xE3069283, got 0x{actual:X8}"); + } + } + + /// Computes the CRC32C of . + public static uint Compute(ReadOnlySpan data) + { + return Compute(data, 0u); + } + + /// + /// Computes the CRC32C of chained from a previous result. + /// + /// Bytes to checksum. + /// Previous CRC32C output, or 0 for a fresh checksum. + public static uint Compute(ReadOnlySpan data, uint seed) + { + // CRC32 convention: invert the running register before processing and again afterwards so + // that chaining (Compute(b, Compute(a))) gives Compute(a + b). + var crc = ~seed; + + var i = 0; + var len = data.Length; + + // Slice-by-8: process 8 bytes per iteration. Reads are little-endian to match the way the + // tables were derived. + while (i + 8 <= len) + { + var word0 = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i, 4)) ^ crc; + var word1 = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(i + 4, 4)); + crc = Tables[7][word0 & 0xFF] + ^ Tables[6][(word0 >> 8) & 0xFF] + ^ Tables[5][(word0 >> 16) & 0xFF] + ^ Tables[4][(word0 >> 24) & 0xFF] + ^ Tables[3][word1 & 0xFF] + ^ Tables[2][(word1 >> 8) & 0xFF] + ^ Tables[1][(word1 >> 16) & 0xFF] + ^ Tables[0][(word1 >> 24) & 0xFF]; + i += 8; + } + + // Tail bytes processed one at a time. + var t0 = Tables[0]; + for (; i < len; i++) + { + crc = t0[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + } + + return ~crc; + } + + /// + /// Step a single byte. Used by the segment-replay code where we want to verify checksums + /// incrementally without re-reading the entire frame. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint UpdateByte(uint runningCrc, byte value) + { + // runningCrc here is the *raw* register state (not the user-visible Compute output). + // Callers chaining via Compute should `~Compute(prev)` to get the register, step bytes, + // and `~register` to get the final value. + return Tables[0][(runningCrc ^ value) & 0xFF] ^ (runningCrc >> 8); + } + + private static uint[][] BuildSliceBy8Tables() + { + var tables = new uint[8][]; + for (var i = 0; i < 8; i++) + { + tables[i] = new uint[256]; + } + + // Table 0: standard CRC32C byte-by-byte table (reflected polynomial). + for (uint i = 0; i < 256; i++) + { + var c = i; + for (var j = 0; j < 8; j++) + { + c = (c & 1) != 0 ? (c >> 1) ^ Polynomial : c >> 1; + } + + tables[0][i] = c; + } + + // Tables 1..7: T_n[b] = T_(n-1)[T_(n-1)[b] & 0xFF] ^ ... no — derive via the standard + // recurrence for slice-by-N: T_n[b] = T_(n-1)[b] right-shifted one byte, XOR'd with the + // standard table indexed by the low byte that fell off. + for (uint i = 0; i < 256; i++) + { + var c = tables[0][i]; + for (var t = 1; t < 8; t++) + { + c = tables[0][c & 0xFF] ^ (c >> 8); + tables[t][i] = c; + } + } + + return tables; + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpCursorSendEngine.cs b/src/net-questdb-client/Qwp/Sf/QwpCursorSendEngine.cs new file mode 100644 index 00000000..a3981a4a --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpCursorSendEngine.cs @@ -0,0 +1,1430 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// SF send engine: owns a slot's segment ring and a background I/O loop that drains envelopes +/// to the server, reconnecting on transient failure. Send and receive run as concurrent pumps — +/// send walks the cursor and ships frames; receive consumes cumulative ACKs and trims the ring. +/// On reconnect the cursor rewinds to the first un-acked FSN so in-flight frames are replayed. +/// +internal sealed class QwpCursorSendEngine : IDisposable +{ + private const int AckBufferSize = 4096; + + // A valid OK ACK is 11 + N*(10 + nameBytes); with up to 65535 touched tables and 127-byte + // names it can reach ~9 MiB, so the receive buffer grows on demand. The cap stays well above + // that worst case while still rejecting a genuinely malformed oversized frame. + private const int AckBufferMaxBytes = QwpConstants.MaxResultBatchWireBytes; + + private readonly QwpSlotLock? _slotLock; + private readonly QwpSegmentRing _ring; + private readonly QwpSegmentManager _segmentManager; + private readonly Func _transportFactory; + private readonly QwpReconnectPolicy _reconnectPolicy; + private readonly TimeSpan _appendDeadline; + private readonly InitialConnectMode _initialConnectMode; + private readonly TaskCompletionSource _firstConnectGate = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Func? _skipBackoffPredicate; + private readonly QwpSenderErrorDispatcher? _errorDispatcher; + private readonly Action? _connectionEventSink; + private (string Host, int Port)? _liveEndpoint; + private long _currentRoundSeq; + private readonly SenderErrorPolicyResolver? _policyResolver; + private readonly QwpAckWatermark? _ackWatermark; + private readonly object _stateLock = new(); + private readonly byte[] _sendBuffer; + // Grows on demand for oversized-but-valid ACKs; reused across receive-pump iterations. + private byte[] _ackBuffer; + private readonly bool _durableAckMode; + private readonly Queue _pendingDurable = new(); + private readonly Dictionary _durableTableWatermarks = new(StringComparer.Ordinal); + + private long _cursorFsn; + private long _ackedFsn; + private long _sentFsnHighWatermark; + private long _totalAcks; + private long _totalFramesSent; + private long _totalServerErrors; + private long _totalReconnectAttempts; + private long _totalReconnectsSucceeded; + private int _negotiatedMaxBatchSize; + private bool _terminal; + private Exception? _terminalError; + private bool _seenFirstConnect; + private bool _disposed; + private bool _started; + + private TaskCompletionSource _appendSignal = NewSignal(); + private TaskCompletionSource _ackSignal = NewSignal(); + + private CancellationTokenSource? _loopCts; + private Task? _loopTask; + private Action? _tableEntryHandler; + + /// + /// The slot lock to dispose alongside the engine. Pass null when the caller (e.g. + /// ) owns the lock externally — the engine will + /// drive the wire but leave the lock alone on dispose. + /// + /// Segment ring backing the engine's frames; the engine owns its disposal. + /// Factory that produces a fresh transport on each (re)connect. + /// Backoff policy applied between transient wire failures. + /// Max time will wait when the disk cap is hit. + /// First-connect retry policy. See . + /// Disk cap forwarded to the engine's . + /// + /// When non-null and returns true after a connect failure, the engine retries + /// immediately instead of waiting the reconnect backoff. Lets multi-host failover walk + /// the full address list before paying the backoff cost. + /// + /// + /// Optional dispatcher that delivers notifications off the + /// I/O thread. The engine never invokes user code directly. + /// + /// + /// Optional override for the per- default policy. + /// + /// + /// When true, trim is driven by frames + /// covering the longest pending-OK prefix, not by + /// frames directly. The caller must have verified the server echoed + /// X-QWP-Durable-Ack: enabled on the upgrade. + /// + /// + /// Optional persisted durable-ack high-water mark. When supplied, refines the startup + /// ackedFsn seed past frames the previous session already had durable-acks for, + /// avoiding row-level re-replay inside the lowest surviving sealed segment. The engine + /// takes ownership and disposes the watermark on shutdown. + /// + public QwpCursorSendEngine( + QwpSlotLock? slotLock, + QwpSegmentRing ring, + Func transportFactory, + QwpReconnectPolicy reconnectPolicy, + TimeSpan appendDeadline, + InitialConnectMode initialConnectMode, + long maxTotalBytes = long.MaxValue, + Func? skipBackoffPredicate = null, + QwpSenderErrorDispatcher? errorDispatcher = null, + SenderErrorPolicyResolver? policyResolver = null, + bool durableAckMode = false, + QwpAckWatermark? ackWatermark = null, + Action? connectionEventSink = null) + { + ArgumentNullException.ThrowIfNull(ring); + ArgumentNullException.ThrowIfNull(transportFactory); + ArgumentNullException.ThrowIfNull(reconnectPolicy); + if (appendDeadline <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(appendDeadline), "must be positive"); + } + + _slotLock = slotLock; + _ring = ring; + _transportFactory = transportFactory; + _reconnectPolicy = reconnectPolicy; + _appendDeadline = appendDeadline; + _initialConnectMode = initialConnectMode; + _skipBackoffPredicate = skipBackoffPredicate; + _errorDispatcher = errorDispatcher; + _policyResolver = policyResolver; + _durableAckMode = durableAckMode; + _ackWatermark = ackWatermark; + _connectionEventSink = connectionEventSink; + + var baseSeed = ring.OldestFsn; + if (ackWatermark is not null) + { + var wm = ackWatermark.Read(); + if (wm != QwpAckWatermark.Invalid) + { + // max() absorbs either ordering of the manager's persist-then-trim tick. + var candidate = Math.Max(baseSeed, wm + 1); + // candidate > NextFsn means corruption: a clean prior session can't produce one. + if (candidate <= ring.NextFsn) + { + baseSeed = candidate; + } + } + } + + _cursorFsn = baseSeed; + _ackedFsn = baseSeed; + if (baseSeed > ring.OldestFsn) + { + ring.Acknowledge(baseSeed - 1); + } + + _segmentManager = new QwpSegmentManager(ring, maxTotalBytes); + _segmentManager.SetAckWatermark(ackWatermark); + _sendBuffer = new byte[ring.SegmentCapacity]; + _ackBuffer = new byte[AckBufferSize]; + // Spare arrival from the manager wakes any producer parked in AppendBlocking. + ring.SetSpareInstalledCallback(() => + { + lock (_stateLock) FireAckSignalLocked(); + }); + } + + /// FSN of the next frame to be appended (== ). + public long NextFsn + { + get + { + lock (_stateLock) + { + return _ring.NextFsn; + } + } + } + + /// First un-acked FSN. NextFsn == AckedFsn means the slot is fully drained. + public long AckedFsn + { + get + { + lock (_stateLock) + { + return _ackedFsn; + } + } + } + + public long TotalFramesSent => Volatile.Read(ref _totalFramesSent); + public long TotalAcks => Volatile.Read(ref _totalAcks); + public long TotalServerErrors => Volatile.Read(ref _totalServerErrors); + public long TotalReconnectAttempts => Volatile.Read(ref _totalReconnectAttempts); + public long TotalReconnectsSucceeded => Volatile.Read(ref _totalReconnectsSucceeded); + + /// + /// Server-advertised hard cap on QWP ingest payload bytes, refreshed on every successful + /// (re)connect. 0 when the current server did not advertise it. + /// + public int NegotiatedMaxBatchSize => Volatile.Read(ref _negotiatedMaxBatchSize); + + /// True once the engine has hit a terminal failure. + public bool IsTerminallyFailed + { + get + { + lock (_stateLock) + { + return _terminal; + } + } + } + + /// The terminal error, if any. + public Exception? TerminalError + { + get + { + lock (_stateLock) + { + return _terminalError; + } + } + } + + /// + /// Optional callback invoked for every per-table entry the server returns in OK / DurableAck + /// frames. The boolean argument is true when the source was a DurableAck. + /// + public void SetTableEntryHandler(Action? handler) + { + Volatile.Write(ref _tableEntryHandler, handler); + } + + /// Launches the I/O loop. Idempotent; subsequent calls are no-ops. + public void Start() + { + lock (_stateLock) + { + EnsureNotDisposed(); + if (_started) + { + return; + } + + _started = true; + _loopCts = new CancellationTokenSource(); + } + + if (_slotLock is not null) + { + _segmentManager.SetHeartbeatCallback(_slotLock.RefreshHeartbeat); + } + _segmentManager.Start(); + _loopTask = Task.Run(() => RunLoopAsync(_loopCts!.Token)); + } + + /// + /// Persists to disk. Blocks the calling thread on backpressure + /// until the ring drains or sf_append_deadline elapses. + /// + public void AppendBlocking(ReadOnlySpan frame, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + if (frame.Length == 0) + { + throw new ArgumentException("empty frames are not permitted", nameof(frame)); + } + + lock (_stateLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + if (_terminal) throw WrapTerminalForProducer(); + + if (_ring.TryAppend(frame)) + { + FireAppendSignalLocked(); + return; + } + } + + AppendBlockingSlow(frame, cancellationToken); + } + + /// + /// Async counterpart of : the returned task completes once the + /// frame has been persisted to the ring or throws on terminal failure / deadline / cancellation. + /// + public ValueTask AppendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + if (frame.Length == 0) + { + throw new ArgumentException("empty frames are not permitted", nameof(frame)); + } + + lock (_stateLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + if (_terminal) throw WrapTerminalForProducer(); + + if (_ring.TryAppend(frame.Span)) + { + FireAppendSignalLocked(); + return ValueTask.CompletedTask; + } + } + + return AppendAsyncSlow(frame, cancellationToken); + } + + private void AppendBlockingSlow(ReadOnlySpan frame, CancellationToken cancellationToken) + { + var rented = ArrayPool.Shared.Rent(frame.Length); + var len = frame.Length; + frame.CopyTo(rented); + try + { + var deadlineMs = Environment.TickCount64 + (long)_appendDeadline.TotalMilliseconds; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + Task waitTask; + lock (_stateLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + if (_terminal) throw WrapTerminalForProducer(); + + if (_ring.TryAppend(rented.AsSpan(0, len))) + { + FireAppendSignalLocked(); + return; + } + + waitTask = _ackSignal.Task; + } + + var remainingMs = deadlineMs - Environment.TickCount64; + if (remainingMs <= 0) + { + var svcErr = _segmentManager.LastServiceError; + var suffix = svcErr is not null + ? $"; last segment-manager error: {svcErr.GetType().Name}: {svcErr.Message}" + : string.Empty; + throw new IngressError( + ErrorCode.ServerFlushError, + $"sf_append_deadline ({_appendDeadline.TotalMilliseconds:F0} ms) expired with the ring full{suffix}"); + } + + try + { + // Bound the wait so a missed signal (e.g. manager's first heartbeat installing + // the initial spare) cannot stall the producer for the full deadline. + var slice = (int)Math.Min(remainingMs, 200); + waitTask.Wait(slice, cancellationToken); + } + catch (AggregateException ex) when (ex.InnerException is OperationCanceledException) + { + throw ex.InnerException; + } + } + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private async ValueTask AppendAsyncSlow(ReadOnlyMemory frame, CancellationToken cancellationToken) + { + var deadlineMs = Environment.TickCount64 + (long)_appendDeadline.TotalMilliseconds; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + Task waitTask; + lock (_stateLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + if (_terminal) throw WrapTerminalForProducer(); + + if (_ring.TryAppend(frame.Span)) + { + FireAppendSignalLocked(); + return; + } + + waitTask = _ackSignal.Task; + } + + var remainingMs = deadlineMs - Environment.TickCount64; + if (remainingMs <= 0) + { + var svcErr = _segmentManager.LastServiceError; + var suffix = svcErr is not null + ? $"; last segment-manager error: {svcErr.GetType().Name}: {svcErr.Message}" + : string.Empty; + throw new IngressError( + ErrorCode.ServerFlushError, + $"sf_append_deadline ({_appendDeadline.TotalMilliseconds:F0} ms) expired with the ring full{suffix}"); + } + + var slice = TimeSpan.FromMilliseconds(Math.Min(remainingMs, 200)); + try + { + await waitTask.WaitAsync(slice, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + } + + /// + /// Blocks until reaches or + /// elapses. Returns true on success, false on + /// timeout. Throws on terminal failure or cancellation. + /// + public async Task AwaitAckedFsnAsync(long targetFsn, TimeSpan timeout, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + var infiniteTimeout = timeout == Timeout.InfiniteTimeSpan; + var deadlineMs = infiniteTimeout + ? long.MaxValue + : Environment.TickCount64 + (long)timeout.TotalMilliseconds; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + Task waitTask; + lock (_stateLock) + { + if (_disposed) throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + if (_terminal) throw WrapTerminalForProducer(); + if (_ackedFsn >= targetFsn) return true; + waitTask = _ackSignal.Task; + } + + if (infiniteTimeout) + { + await waitTask.WaitAsync(cancellationToken).ConfigureAwait(false); + continue; + } + + var remainingMs = deadlineMs - Environment.TickCount64; + if (remainingMs <= 0) return false; + + try + { + await waitTask.WaitAsync(TimeSpan.FromMilliseconds(remainingMs), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + } + + /// Returns once every appended frame is acked, or throws on timeout / terminal failure. + public async Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + EnsureNotDisposed(); + var infiniteTimeout = timeout == Timeout.InfiniteTimeSpan; + var deadlineMs = infiniteTimeout + ? long.MaxValue + : Environment.TickCount64 + (long)timeout.TotalMilliseconds; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + Task waitTask; + lock (_stateLock) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + } + + if (_terminal) + { + throw WrapTerminalForProducer(); + } + + if (_ackedFsn >= _ring.NextFsn) + { + return; + } + + waitTask = _ackSignal.Task; + } + + if (infiniteTimeout) + { + // Task.WaitAsync(TimeSpan, ct) rejects timeouts above ~49.7 days; route infinite waits + // through the no-timeout overload to avoid ArgumentOutOfRangeException. + await waitTask.WaitAsync(cancellationToken).ConfigureAwait(false); + continue; + } + + var remainingMs = deadlineMs - Environment.TickCount64; + if (remainingMs <= 0) + { + throw new TimeoutException( + $"close_flush_timeout ({timeout.TotalMilliseconds:F0} ms) expired with un-acked frames pending"); + } + + try + { + await waitTask.WaitAsync(TimeSpan.FromMilliseconds(remainingMs), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + } + } + } + + /// + public void Dispose() + { + CancellationTokenSource? cts; + Task? loop; + bool fullyDrained; + string? slotDir; + lock (_stateLock) + { + if (_disposed) + { + return; + } + + Volatile.Write(ref _disposed, true); + cts = _loopCts; + loop = _loopTask; + // Capture drain state BEFORE disposing the ring — once disposed, NextFsn isn't safe to read. + fullyDrained = _ackedFsn >= _ring.NextFsn; + slotDir = _ring.Directory; + // Wake blocked producers so they can observe _disposed and throw. + FireAckSignalLocked(); + FireAppendSignalLocked(); + } + + SfCleanup.Run(() => cts?.Cancel()); + _segmentManager.RequestShutdown(); + + // Defer ring/lock release if either pump is still alive: tearing down shared state would + // crash on disposed mmaps or let another sender poach the slot mid-send. + var pending = new[] { loop, _segmentManager.WorkerTask } + .Where(t => t is not null) + .Cast() + .ToArray(); + var allJoined = pending.Length == 0 || SafeWaitAll(pending, TimeSpan.FromSeconds(5)); + + if (!allJoined) + { + // Pumps still alive; leak _loopCts rather than risk ObjectDisposedException on a late + // token read. The continuation disposes it once both pumps actually exit. + Task.WhenAll(pending).ContinueWith( + _ => { ReleaseSharedResources(fullyDrained, slotDir); SfCleanup.Dispose(cts); }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return; + } + + SfCleanup.Dispose(cts); + ReleaseSharedResources(fullyDrained, slotDir); + } + + private static bool SafeWaitAll(Task[] tasks, TimeSpan timeout) + { + try { return Task.WaitAll(tasks, timeout); } + catch { return true; } + } + + private void ReleaseSharedResources(bool fullyDrained, string? slotDir) + { + SfCleanup.Dispose(_segmentManager); + SfCleanup.Dispose(_ring); + SfCleanup.Dispose(_ackWatermark); + + if (fullyDrained && slotDir is not null) + { + UnlinkSegmentFiles(slotDir); + QwpAckWatermark.RemoveOrphan(slotDir); + } + + SfCleanup.Dispose(_slotLock); + } + + private static void UnlinkSegmentFiles(string slotDirectory) + { + try + { + foreach (var path in QwpFiles.EnumerateFiles(slotDirectory, "sf-*.sfa")) + { + SfCleanup.DeleteFile(path); + } + } + catch (Exception) + { + // Best-effort — slot dir may have been removed externally. + } + } + + private async Task RunLoopAsync(CancellationToken ct) + { + try + { + await RunLoopBodyAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + SetTerminal(ex); + } + finally + { + bool needFallback; + lock (_stateLock) { needFallback = !_terminal; } + if (needFallback) + { + FireFirstConnectFailed( + new IngressError(ErrorCode.SocketError, "SF engine loop exited before first connect")); + } + } + } + + private async Task RunLoopBodyAsync(CancellationToken ct) + { + var backoff = new BackoffState(); + + while (!ct.IsCancellationRequested) + { + IQwpCursorTransport? transport; + try + { + transport = _transportFactory(); + } + catch (Exception ex) + { + SetTerminal(ex); + return; + } + + try + { + Interlocked.Increment(ref _totalReconnectAttempts); + try + { + await transport.ConnectAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (IngressError ex) when ( + ex.code is ErrorCode.AuthError or ErrorCode.DurableAckNotSupported) + { + if (ex.code is ErrorCode.AuthError) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.AuthFailed, cause: ex); + } + SetTerminal(ex); + return; + } + catch (QwpIngressRoleRejectedException ex) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.EndpointAttemptFailed, + cause: ex, endpoint: transport.Endpoint); + backoff.ResetAttempt(); + backoff.OutageStartTickMs ??= Environment.TickCount64; + var elapsed = TimeSpan.FromMilliseconds( + Environment.TickCount64 - backoff.OutageStartTickMs.Value); + if (elapsed >= _reconnectPolicy.MaxOutageDuration) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.ReconnectBudgetExhausted, + cause: ex, endpoint: transport.Endpoint); + SetTerminal(ex); + return; + } + + if (_skipBackoffPredicate?.Invoke() == true) + { + continue; + } + + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.AllEndpointsUnreachable, + cause: ex, endpoint: transport.Endpoint); + Interlocked.Increment(ref _currentRoundSeq); + + var remaining = _reconnectPolicy.MaxOutageDuration - elapsed; + var jittered = _reconnectPolicy.ComputeBackoff(0); + var sleep = remaining < jittered ? remaining : jittered; + try + { + await Task.Delay(sleep, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + elapsed = TimeSpan.FromMilliseconds( + Environment.TickCount64 - backoff.OutageStartTickMs.Value); + if (elapsed >= _reconnectPolicy.MaxOutageDuration) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.ReconnectBudgetExhausted, + cause: ex, endpoint: transport.Endpoint); + SetTerminal(ex); + return; + } + continue; + } + catch (Exception ex) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.EndpointAttemptFailed, + cause: ex, endpoint: transport.Endpoint); + + if (_skipBackoffPredicate?.Invoke() == true) + { + continue; + } + + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.AllEndpointsUnreachable, + cause: ex, endpoint: transport.Endpoint); + Interlocked.Increment(ref _currentRoundSeq); + + if (!_seenFirstConnect && _initialConnectMode == InitialConnectMode.off) + { + SetTerminal(ex); + return; + } + + if (!await BackoffOrGiveUpAsync(ex, backoff, ct).ConfigureAwait(false)) + { + return; + } + + continue; + } + + var wasFirst = !_seenFirstConnect; + var newEndpoint = transport.Endpoint; + var prevEndpoint = _liveEndpoint; + _seenFirstConnect = true; + _liveEndpoint = newEndpoint; + Volatile.Write(ref _negotiatedMaxBatchSize, transport.NegotiatedMaxBatchSize); + Interlocked.Increment(ref _totalReconnectsSucceeded); + backoff.Reset(); + FireFirstConnectSucceeded(); + + QuestDB.Senders.SenderConnectionEventKind successKind; + if (wasFirst) + { + successKind = QuestDB.Senders.SenderConnectionEventKind.Connected; + } + else if (newEndpoint is not null && prevEndpoint is not null + && (newEndpoint.Value.Host != prevEndpoint.Value.Host + || newEndpoint.Value.Port != prevEndpoint.Value.Port)) + { + successKind = QuestDB.Senders.SenderConnectionEventKind.FailedOver; + } + else + { + successKind = QuestDB.Senders.SenderConnectionEventKind.Reconnected; + } + EmitConnectionEvent(successKind, cause: null, endpoint: newEndpoint); + + long fsnAtZero; + lock (_stateLock) + { + // Invariant: the manager only trims segments at or below ackedFsn-1, so the + // ring's oldest FSN must never sit ahead of the cursor's resume point. + // A violation means trim and ack are out of sync — surface it as terminal + // rather than silently skip un-acked frames at the ring head. + if (_ackedFsn < _ring.OldestFsn) + { + throw new InvalidDataException( + $"cursor invariant violation: ackedFsn={_ackedFsn} < ring.OldestFsn={_ring.OldestFsn}"); + } + _cursorFsn = _ackedFsn; + fsnAtZero = _cursorFsn; + _sentFsnHighWatermark = _cursorFsn - 1; + // Pending durable state is per-connection (wireSeq is reset on every upgrade); + // the new server replays cumulative watermarks from scratch. + _pendingDurable.Clear(); + _durableTableWatermarks.Clear(); + } + + try + { + await RunConnectionAsync(transport, fsnAtZero, ct).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) + { + return; + } + catch (HaltCarrier hc) + { + SetTerminal(hc.Wire, hc.SenderError); + return; + } + catch (QwpProtocolViolationException ex) + { + SetTerminal(ex, BuildProtocolViolationError(ex)); + return; + } + catch (Exception ex) when (IsTerminalServerError(ex)) + { + SetTerminal(ex); + return; + } + catch (Exception ex) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.Disconnected, + cause: ex, endpoint: _liveEndpoint); + _liveEndpoint = null; + if (!await BackoffOrGiveUpAsync(ex, backoff, ct).ConfigureAwait(false)) + { + return; + } + } + } + finally + { + SfCleanup.Dispose(transport); + } + } + } + + private sealed class BackoffState + { + public int Attempt; + public long? OutageStartTickMs; + + public void Reset() + { + Attempt = 0; + OutageStartTickMs = null; + } + + // Role-reject path resets attempt counter but preserves the outage clock so the + // wall-clock budget still bounds a stuck PRIMARY_CATCHUP topology. + public void ResetAttempt() + { + Attempt = 0; + } + } + + // Pipelined send + receive: two pumps share state under _stateLock. A linked CTS means a fault + // in either pump cancels the other; the connection then closes and the outer loop reconnects. + private async Task RunConnectionAsync(IQwpCursorTransport transport, long fsnAtZero, CancellationToken ct) + { + using var connCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var connToken = connCts.Token; + var sendTask = Task.Run(() => SendPumpAsync(transport, connToken)); + var recvTask = Task.Run(() => ReceivePumpAsync(transport, fsnAtZero, connToken)); + + try + { + await Task.WhenAny(sendTask, recvTask).ConfigureAwait(false); + } + finally + { + connCts.Cancel(); + } + + try + { + await Task.WhenAll(sendTask, recvTask).ConfigureAwait(false); + } + catch (Exception) + { + } + + // Both pumps can fault simultaneously when the server closes the socket: recv sees the + // CLOSE frame while send's in-flight write fails with a generic transport error. Prefer + // the terminal fault so QwpProtocolViolationException / QwpException don't get masked + // by the concurrent send-side WebSocketException, which would otherwise route through + // the transient reconnect path. + var sendFault = sendTask.Exception?.GetBaseException(); + var recvFault = recvTask.Exception?.GetBaseException(); + var fault = PickTerminalFault(sendFault, recvFault) ?? sendFault ?? recvFault; + if (fault is not null) + { + throw fault; + } + + if (sendTask.IsCanceled || recvTask.IsCanceled) + { + throw new OperationCanceledException(connCts.Token); + } + + // Both pumps returned without throwing OCE: graceful shutdown via outer ct or terminal. + } + + private async Task SendPumpAsync(IQwpCursorTransport transport, CancellationToken ct) + { + var sendBuffer = _sendBuffer; + + while (!ct.IsCancellationRequested) + { + long readFsn; + int frameLen; + + while (true) + { + Task wait; + lock (_stateLock) + { + if (_cursorFsn < _ring.NextFsn) + { + readFsn = _cursorFsn; + frameLen = _ring.TryReadFrame(readFsn, sendBuffer); + if (frameLen < 0) + { + // Terminal (not transient): an unreadable published FSN is corruption + // that replaying cannot recover, so don't reconnect-loop over it. + throw new InvalidDataException( + $"QWP segment ring: published FSN {readFsn} is no longer readable " + + "(corrupt or truncated segment)"); + } + + // Watermark must bump atomically with cursor; a fast server ACK racing ahead + // of SendBinaryAsync completion would otherwise be dropped by the receive pump. + _cursorFsn = readFsn + 1; + if (readFsn > _sentFsnHighWatermark) + { + _sentFsnHighWatermark = readFsn; + } + break; + } + + wait = _appendSignal.Task; + } + + await wait.WaitAsync(ct).ConfigureAwait(false); + } + + await transport.SendBinaryAsync(sendBuffer.AsMemory(0, frameLen), ct).ConfigureAwait(false); + Interlocked.Increment(ref _totalFramesSent); + } + } + + private async Task ReceivePumpAsync(IQwpCursorTransport transport, long fsnAtZero, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + var (ackLen, ackBuffer) = await transport + .ReceiveFrameAsync(_ackBuffer, AckBufferMaxBytes, ct).ConfigureAwait(false); + // Keep a grown buffer so the next iteration doesn't reallocate on a recurring wide ACK. + _ackBuffer = ackBuffer; + var response = QwpResponse.Parse(ackBuffer.AsSpan(0, ackLen)); + + if (!response.IsOk && !response.IsDurableAck) + { + Interlocked.Increment(ref _totalServerErrors); + HandleServerRejection(response, fsnAtZero); + continue; + } + + DispatchTableEntries(response); + + if (response.IsDurableAck) + { + if (_durableAckMode) HandleDurableAck(response, fsnAtZero); + continue; + } + + Interlocked.Increment(ref _totalAcks); + + var ackedSeq = response.Sequence; + if (ackedSeq < 0) + { + throw new IngressError( + ErrorCode.ServerFlushError, + $"server returned negative ack sequence {ackedSeq}"); + } + + if (_durableAckMode) + { + HandleOkInDurableMode(response, ackedSeq, fsnAtZero); + continue; + } + + lock (_stateLock) + { + var highestSentWireSeq = _sentFsnHighWatermark - fsnAtZero; + if (highestSentWireSeq < 0) + { + continue; + } + + if (ackedSeq > highestSentWireSeq) + { + ackedSeq = highestSentWireSeq; + } + + var newAcked = checked(fsnAtZero + ackedSeq + 1); + if (newAcked > _ackedFsn) + { + _ackedFsn = newAcked; + _ring.Acknowledge(_ackedFsn - 1); + FireAckSignalLocked(); + } + } + } + } + + private void HandleOkInDurableMode(in QwpResponse response, long ackedSeq, long fsnAtZero) + { + var entries = response.TableEntries.Count == 0 + ? Array.Empty() + : response.TableEntries.ToArray(); + + lock (_stateLock) + { + var highestSentWireSeq = _sentFsnHighWatermark - fsnAtZero; + if (highestSentWireSeq < 0) return; + + // Cap the wire seq instead of dropping the frame: cumulative ACK semantics mean a + // sequence beyond our highest sent is still a valid commitment of everything we've + // sent, and the queued per-table watermarks must still drive TrimCoveredPrefix. + if (ackedSeq > highestSentWireSeq) ackedSeq = highestSentWireSeq; + + _pendingDurable.Enqueue(new PendingDurable(ackedSeq, entries)); + TrimCoveredPrefixLocked(fsnAtZero); + } + } + + private void HandleDurableAck(in QwpResponse response, long fsnAtZero) + { + lock (_stateLock) + { + foreach (var entry in response.TableEntries) + { + if (!_durableTableWatermarks.TryGetValue(entry.TableName, out var prev) || entry.SeqTxn > prev) + { + _durableTableWatermarks[entry.TableName] = entry.SeqTxn; + } + } + TrimCoveredPrefixLocked(fsnAtZero); + } + } + + // Drain pendingDurable head while head is covered (empty entries, or every (table, seqTxn) + // ≤ watermark). Caller holds _stateLock. + private void TrimCoveredPrefixLocked(long fsnAtZero) + { + var maxPoppedWireSeq = -1L; + while (_pendingDurable.Count > 0) + { + var head = _pendingDurable.Peek(); + if (!IsCoveredLocked(head)) break; + _pendingDurable.Dequeue(); + maxPoppedWireSeq = head.WireSeq; + } + + if (maxPoppedWireSeq < 0) return; + + var newAcked = checked(fsnAtZero + maxPoppedWireSeq + 1); + if (newAcked > _ackedFsn) + { + _ackedFsn = newAcked; + _ring.Acknowledge(_ackedFsn - 1); + FireAckSignalLocked(); + } + } + + private bool IsCoveredLocked(PendingDurable entry) + { + // Empty entries are trivially durable — no per-table commit to wait for. + if (entry.Entries.Length == 0) return true; + foreach (var t in entry.Entries) + { + if (!_durableTableWatermarks.TryGetValue(t.TableName, out var w) || w < t.SeqTxn) + { + return false; + } + } + return true; + } + + private readonly record struct PendingDurable(long WireSeq, QwpTableEntry[] Entries); + + private void HandleServerRejection(in QwpResponse response, long fsnAtZero) + { + var category = QwpErrorClassifier.Classify(response.Status); + var policy = QwpErrorClassifier.ResolvePolicy(category, _policyResolver); + + var wireSeq = response.Sequence; + if (wireSeq < 0) + { + // Symmetry with the OK path's negative-seq rejection: error frames also follow the + // non-negative-sequence wire contract. + throw new IngressError( + ErrorCode.ServerFlushError, + $"server returned negative error sequence {wireSeq}"); + } + long fromFsn, toFsn; + long highestSentWireSeq; + lock (_stateLock) + { + highestSentWireSeq = _sentFsnHighWatermark - fsnAtZero; + } + + if (highestSentWireSeq < 0) + { + // Pre-send reject (server hit us before any frame went out on this connection): + // the wire seq doesn't map to a real FSN, so skip the watermark advance. + fromFsn = -1L; + toFsn = -1L; + } + else + { + var capped = Math.Min(wireSeq, highestSentWireSeq); + fromFsn = checked(fsnAtZero + capped); + toFsn = fromFsn; + } + + var tableName = response.TableEntries.Count == 1 ? response.TableEntries[0].TableName : null; + var senderError = new SenderError( + category, + policy, + (byte)response.Status, + response.Message, + wireSeq, + fromFsn, + toFsn, + tableName, + DateTime.UtcNow); + + if (policy == SenderErrorPolicy.Halt) + { + throw new HaltCarrier(senderError, new LineSenderServerException(senderError)); + } + + if (fromFsn >= 0L) + { + lock (_stateLock) + { + if (_durableAckMode) + { + var cappedSeq = fromFsn - fsnAtZero; + _pendingDurable.Enqueue(new PendingDurable(cappedSeq, Array.Empty())); + TrimCoveredPrefixLocked(fsnAtZero); + } + else + { + var newAcked = checked(fromFsn + 1L); + if (newAcked > _ackedFsn) + { + _ackedFsn = newAcked; + _ring.Acknowledge(_ackedFsn - 1); + FireAckSignalLocked(); + } + } + } + } + + _errorDispatcher?.Offer(senderError); + } + + private void DispatchTableEntries(in QwpResponse response) + { + var handler = Volatile.Read(ref _tableEntryHandler); + if (handler is null || response.TableEntries.Count == 0) + { + return; + } + + var isDurable = response.IsDurableAck; + for (var i = 0; i < response.TableEntries.Count; i++) + { + handler(response.TableEntries[i], isDurable); + } + } + + private async Task BackoffOrGiveUpAsync(Exception lastError, BackoffState state, CancellationToken ct) + { + state.OutageStartTickMs ??= Environment.TickCount64; + var elapsed = TimeSpan.FromMilliseconds(Environment.TickCount64 - state.OutageStartTickMs.Value); + var next = _reconnectPolicy.NextBackoffOrGiveUp(state.Attempt, elapsed); + if (next is null) + { + EmitConnectionEvent(QuestDB.Senders.SenderConnectionEventKind.ReconnectBudgetExhausted, cause: lastError); + SetTerminal(lastError); + return false; + } + + try + { + await Task.Delay(next.Value, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return false; + } + + state.Attempt++; + return true; + } + + private void SetTerminal(Exception error, SenderError? senderError = null) + { + bool isInitialConnect; + lock (_stateLock) + { + if (_terminal) + { + return; + } + + _terminal = true; + _terminalError = error; + isInitialConnect = !_seenFirstConnect; + FireAckSignalLocked(); + FireAppendSignalLocked(); + } + FireFirstConnectFailed(error); + if (_errorDispatcher is null) return; + _errorDispatcher.Offer(senderError ?? BuildEngineError(error, isInitialConnect)); + } + + private static SenderError BuildEngineError(Exception error, bool isInitialConnect) => + new( + category: SenderErrorCategory.Unknown, + appliedPolicy: SenderErrorPolicy.Halt, + serverStatusByte: SenderError.NoStatusByte, + serverMessage: error.Message, + messageSequence: SenderError.NoMessageSequence, + fromFsn: -1L, + toFsn: -1L, + tableName: null, + detectedAtUtc: DateTime.UtcNow, + exception: error, + isInitialConnect: isInitialConnect); + + private SenderError BuildProtocolViolationError(QwpProtocolViolationException error) + { + long fromFsn, toFsn; + lock (_stateLock) + { + // _ackedFsn is the first un-acked FSN; the highest published FSN is NextFsn - 1. + // A fully-drained slot leaves fromFsn > toFsn — by convention, an empty span. + fromFsn = _ackedFsn; + toFsn = _ring.NextFsn - 1L; + } + + return new SenderError( + category: SenderErrorCategory.ProtocolViolation, + appliedPolicy: SenderErrorPolicy.Halt, + serverStatusByte: SenderError.NoStatusByte, + serverMessage: error.Message, + messageSequence: SenderError.NoMessageSequence, + fromFsn: fromFsn, + toFsn: toFsn, + tableName: null, + detectedAtUtc: DateTime.UtcNow, + exception: error, + isInitialConnect: !_seenFirstConnect); + } + + /// + /// Completes when the engine has either successfully established its first connection or + /// reached a terminal state. Used by SF mode and + /// to gate the user-facing constructor on first + /// connect; simply doesn't await it. + /// + public Task FirstConnectTask => _firstConnectGate.Task; + + private static readonly Action FireSignalCallback = + static state => ((TaskCompletionSource)state!).TrySetResult(true); + + private void FireAppendSignalLocked() + { + var prev = _appendSignal; + _appendSignal = NewSignal(); + _ = Task.Factory.StartNew(FireSignalCallback, prev, + CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + private void FireAckSignalLocked() + { + var prev = _ackSignal; + _ackSignal = NewSignal(); + _ = Task.Factory.StartNew(FireSignalCallback, prev, + CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + private void FireFirstConnectSucceeded() + { + var gate = _firstConnectGate; + _ = Task.Factory.StartNew(static s => ((TaskCompletionSource)s!).TrySetResult(true), + gate, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + private void EmitConnectionEvent( + QuestDB.Senders.SenderConnectionEventKind kind, + Exception? cause, + (string Host, int Port)? endpoint = null, + long? attemptNumberOverride = null, + long? roundNumberOverride = null) + { + var sink = _connectionEventSink; + if (sink is null) return; + var attempt = attemptNumberOverride ?? Volatile.Read(ref _totalReconnectAttempts); + var round = roundNumberOverride ?? Volatile.Read(ref _currentRoundSeq); + var evt = new QuestDB.Senders.SenderConnectionEvent( + kind, + host: endpoint?.Host, + port: endpoint?.Port ?? QuestDB.Senders.SenderConnectionEvent.NoPort, + attemptNumber: attempt, + roundNumber: round, + cause: cause, + timestamp: DateTimeOffset.UtcNow); + try { sink(evt); } catch { /* dispatcher absorbs listener exceptions */ } + } + + private void FireFirstConnectFailed(Exception error) + { + var gate = _firstConnectGate; + var captured = error; + _ = Task.Factory.StartNew(static s => + { + var (g, e) = ((TaskCompletionSource, Exception))s!; + g.TrySetException(e); + }, + (gate, captured), CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + /// + /// means async + /// continuations are queued to rather than running + /// inline on the completing thread, so callers can safely fire the signal under + /// _stateLock without deadlocking awaiters that re-enter the same lock. + /// + private static TaskCompletionSource NewSignal() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private void EnsureNotDisposed() + { + if (Volatile.Read(ref _disposed)) + { + throw new ObjectDisposedException(nameof(QwpCursorSendEngine)); + } + } + + private IngressError WrapTerminalForProducer() + { + var inner = _terminalError; + if (inner is LineSenderServerException lse) return lse; + var code = inner is IngressError ie ? ie.code : ErrorCode.ServerFlushError; + var message = inner?.Message ?? "QWP cursor engine has terminally failed"; + return inner is null + ? new IngressError(code, message) + : new IngressError(code, "QWP cursor engine has terminally failed; see inner exception", inner); + } + + // Also consulted by QwpBackgroundDrainerPool to decide whether an orphan-drain fault is + // deterministic (quarantine the slot) or transient (leave it for re-adoption). + internal static bool IsTerminalServerError(Exception ex) + { + return ex is QwpException + || ex is QwpProtocolViolationException + || ex is HaltCarrier + || ex is InvalidDataException + || (ex is IngressError ie && ie.code is ErrorCode.AuthError); + } + + private static Exception? PickTerminalFault(Exception? a, Exception? b) + { + if (a is not null && IsTerminalServerError(a)) return a; + if (b is not null && IsTerminalServerError(b)) return b; + return null; + } + + private sealed class HaltCarrier : Exception + { + public HaltCarrier(SenderError err, LineSenderServerException wire) + : base(wire.Message, wire) + { + SenderError = err; + Wire = wire; + } + + public SenderError SenderError { get; } + public LineSenderServerException Wire { get; } + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpErrorClassifier.cs b/src/net-questdb-client/Qwp/Sf/QwpErrorClassifier.cs new file mode 100644 index 00000000..8dbfe813 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpErrorClassifier.cs @@ -0,0 +1,61 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +internal static class QwpErrorClassifier +{ + public static SenderErrorCategory Classify(QwpStatusCode status) => + status switch + { + QwpStatusCode.SchemaMismatch => SenderErrorCategory.SchemaMismatch, + QwpStatusCode.ParseError => SenderErrorCategory.ParseError, + QwpStatusCode.InternalError => SenderErrorCategory.InternalError, + QwpStatusCode.SecurityError => SenderErrorCategory.SecurityError, + QwpStatusCode.WriteError => SenderErrorCategory.WriteError, + _ => SenderErrorCategory.Unknown, + }; + + public static SenderErrorPolicy DefaultPolicy(SenderErrorCategory category) => + category switch + { + SenderErrorCategory.SchemaMismatch => SenderErrorPolicy.DropAndContinue, + SenderErrorCategory.WriteError => SenderErrorPolicy.DropAndContinue, + _ => SenderErrorPolicy.Halt, + }; + + public static SenderErrorPolicy ResolvePolicy( + SenderErrorCategory category, + SenderErrorPolicyResolver? resolver) + { + if (category is SenderErrorCategory.ProtocolViolation or SenderErrorCategory.Unknown) + { + return SenderErrorPolicy.Halt; + } + return resolver?.Invoke(category) ?? DefaultPolicy(category); + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpFallocate.cs b/src/net-questdb-client/Qwp/Sf/QwpFallocate.cs new file mode 100644 index 00000000..055e3ce8 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpFallocate.cs @@ -0,0 +1,200 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace QuestDB.Qwp.Sf; + +/// +/// Reserves on-disk blocks for an SF segment file before mmap-write, so a later +/// SIGBUS / ENOSPC on a faulted page is impossible on a healthy filesystem +/// with a supported preallocation API. +/// +/// +/// Preallocation strategy: +/// +/// Linux: posix_fallocate guarantees blocks for [0, length). +/// macOS: fcntl(F_PREALLOCATE) with F_PEOFPOSMODE reserves blocks +/// starting at the current physical EOF, so it runs before +/// grows the file — reserving the segment's actual +/// [0, length) range — and then sets the +/// logical size. +/// Windows: on NTFS / ReFS already extends with +/// allocated clusters. +/// +/// When the native call cannot run on the current target (unsupported filesystem, missing +/// libc symbol, an unrecognised architecture's variadic ABI), the routine falls back to +/// alone. Page-walking the file would be the obvious +/// belt-and-braces fallback but it (a) does not actually reserve blocks on copy-on-write +/// filesystems and (b) clobbers any bytes the caller had already written, so it is omitted. +/// +internal static class QwpFallocate +{ + public static void Reserve(FileStream fs, long length) + { + if (length <= 0) return; + + var handle = fs.SafeFileHandle; + var haveHandle = handle is not null && !handle.IsInvalid && !handle.IsClosed; + + if (haveHandle && RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // Must precede SetLength: F_PREALLOCATE reserves from the physical EOF (see remarks). + var toReserve = length - fs.Length; + if (toReserve > 0) TryApplePreallocate(handle!, toReserve); + if (fs.Length < length) fs.SetLength(length); + return; + } + + if (fs.Length < length) fs.SetLength(length); + if (!haveHandle) return; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + TryPosixFallocate(handle!, length); + } + // Windows / unknown: SetLength is sufficient. + } + + private static void TryPosixFallocate(SafeFileHandle handle, long length) + { + int rc; + try + { + rc = posix_fallocate(handle, 0, length); + } + catch (EntryPointNotFoundException) + { + return; + } + catch (DllNotFoundException) + { + return; + } + + if (rc == 0) return; + // EOPNOTSUPP=95 / EINVAL=22 indicate the filesystem doesn't implement the call. + // SetLength alone has already given us a logically-sized file; on these filesystems a + // later write may still hit ENOSPC, but that's the contract the host filesystem offers. + if (rc is 95 or 22) return; + throw new IOException($"posix_fallocate(length={length}) failed with errno {rc}"); + } + + private static void TryApplePreallocate(SafeFileHandle handle, long bytesToReserve) + { + var size = Marshal.SizeOf(); + var ptr = Marshal.AllocHGlobal(size); + try + { + // Try a contiguous reservation first, then fall back to non-contiguous. + if (TryPreallocateOnce(handle, ptr, F_ALLOCATECONTIG, bytesToReserve)) return; + if (TryPreallocateOnce(handle, ptr, F_ALLOCATEALL, bytesToReserve)) return; + + // Best-effort: the caller still SetLength's the file afterwards. Surface the failure + // for diagnosis (e.g. genuine ENOSPC) but don't abort — a later write may succeed. + System.Diagnostics.Trace.TraceWarning( + "QWP fallocate: F_PREALLOCATE (bytes={0}) did not reserve the requested blocks; " + + "block reservation skipped, relying on SetLength.", bytesToReserve); + } + finally + { + Marshal.FreeHGlobal(ptr); + } + } + + // fcntl is variadic — int fcntl(int fildes, int cmd, ...) — and .NET cannot emit a C varargs call + // (__arglist throws "Vararg calling convention not supported" on arm64), so ApplePreallocate uses a + // per-architecture fixed-signature P/Invoke to land the fstore_t* where libc's va_arg reads it. + // Success is still confirmed by reading fst_bytesalloc back, not by rc, so any unforeseen ABI + // mismatch degrades to the SetLength fallback instead of reporting a false success. + private static bool TryPreallocateOnce(SafeFileHandle handle, IntPtr ptr, uint flags, long bytesToReserve) + { + var fstore = new fstore_t + { + fst_flags = flags, + fst_posmode = F_PEOFPOSMODE, + fst_offset = 0, + fst_length = bytesToReserve, + fst_bytesalloc = 0, + }; + Marshal.StructureToPtr(fstore, ptr, fDeleteOld: false); + int rc; + try + { + rc = ApplePreallocate(handle, ptr); + } + catch (EntryPointNotFoundException) + { + return false; + } + catch (DllNotFoundException) + { + return false; + } + + if (rc != 0) return false; + return Marshal.PtrToStructure(ptr).fst_bytesalloc >= bytesToReserve; + } + + // Apple arm64 passes the named args (fildes, cmd) in x0/x1 and every variadic arg on the stack, so + // six dummy register args fill x2..x7 and force `arg` (the 9th) to spill to [sp,#0] — the slot the + // variadic callee reads its first ... argument from. x86-64 SysV instead passes it in rdx, matching + // a plain 3-arg call. macOS runs only on these two architectures. + private static int ApplePreallocate(SafeFileHandle handle, IntPtr arg) + { + if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + return fcntl_arm64(handle, F_PREALLOCATE, 0, 0, 0, 0, 0, 0, arg); + } + + return fcntl(handle, F_PREALLOCATE, arg); + } + + [DllImport("libc", SetLastError = false)] + private static extern int posix_fallocate(SafeFileHandle fd, long offset, long len); + + [DllImport("libc", EntryPoint = "fcntl", SetLastError = true)] + private static extern int fcntl(SafeFileHandle fd, int cmd, IntPtr arg); + + [DllImport("libc", EntryPoint = "fcntl", SetLastError = true)] + private static extern int fcntl_arm64( + SafeFileHandle fd, int cmd, + long pad2, long pad3, long pad4, long pad5, long pad6, long pad7, IntPtr arg); + + private const int F_PREALLOCATE = 42; + private const uint F_ALLOCATECONTIG = 0x00000002; + private const uint F_ALLOCATEALL = 0x00000004; + private const int F_PEOFPOSMODE = 3; + + [StructLayout(LayoutKind.Sequential)] + private struct fstore_t + { + public uint fst_flags; + public int fst_posmode; + public long fst_offset; + public long fst_length; + public long fst_bytesalloc; + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpFiles.cs b/src/net-questdb-client/Qwp/Sf/QwpFiles.cs new file mode 100644 index 00000000..bd1dd0d6 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpFiles.cs @@ -0,0 +1,310 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Diagnostics; +using System.IO.MemoryMappedFiles; +using System.Runtime.InteropServices; + +namespace QuestDB.Qwp.Sf; + +/// +/// Thin wrapper around the file-I/O primitives used by store-and-forward. +/// +/// +/// Centralises the platform-specific bits: advisory exclusive-lock semantics +/// (, ), memory-mapped segment +/// creation (), and page-size discovery for segment +/// sizing. +/// +/// The and helpers use +/// as a portable advisory lock — held for the lifetime of the +/// returned , released on dispose or kernel-on-process-exit. +/// This is unreliable on networked filesystems (NFS/SMB); SF is documented as local-FS only. +/// +internal static class QwpFiles +{ + /// + /// Opens with , claiming an exclusive + /// advisory lock for as long as the returned stream is alive. Throws if another process or + /// thread already holds the lock. + /// + public static FileStream OpenExclusive(string path) + { + return new FileStream( + path, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 4096, + FileOptions.None); + } + + /// + /// Like but returns null instead of throwing when the + /// file is already locked. Missing-directory / permission / other I/O errors propagate. + /// + public static FileStream? TryOpenExclusive(string path) + { + try + { + return OpenExclusive(path); + } + catch (IOException ex) when (IsSharingViolation(ex)) + { + return null; + } + } + + private static bool IsSharingViolation(IOException ex) + { + if (ex is FileNotFoundException || ex is DirectoryNotFoundException || ex is PathTooLongException) + { + return false; + } + + const int sharingViolationHResult = unchecked((int)0x80070020); + if (ex.HResult == sharingViolationHResult) + { + return true; + } + + // POSIX surfaces FileShare.None as a plain IOException with no recognisable HResult, and + // errno narrowing is unreliable cross-platform — so a real I/O fault (ENOSPC/EROFS/EIO) is + // indistinguishable from a lock collision. Treat it as locked (conservative: never + // double-claims a slot) but trace it so a masked fault stays diagnosable. + if (ex.GetType() != typeof(IOException)) + { + return false; + } + + Trace.TraceInformation($"QwpFiles: IOException treated as a slot-lock collision: {ex}"); + return true; + } + + /// + /// Opens or creates a fixed-size memory-mapped file at . The file is + /// pre-extended to if smaller; subsequent + /// calls give writeable views. + /// + /// + /// + /// The file is opened with so other processes can inspect + /// segments out-of-band (e.g. orphan-scanner drainers); writes still go through the same + /// mmap region from the owning process. + /// + /// Caller is responsible for disposing the returned , which + /// also releases the underlying file handle. + /// + public static (MemoryMappedFile Mmap, FileStream FileStream) OpenMemoryMappedSegment(string path, long capacityBytes) + { + if (capacityBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacityBytes)); + } + + EnsureFileLength(path, capacityBytes); + + // FileStream.Flush(true) → FlushFileBuffers on Windows; mmap view's Flush alone is not durable there. + FileStream? fs = null; + try + { + fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + var mmap = MemoryMappedFile.CreateFromFile( + fs, + mapName: null, + capacityBytes, + MemoryMappedFileAccess.ReadWrite, + HandleInheritability.None, + leaveOpen: true); + return (mmap, fs); + } + catch + { + fs?.Dispose(); + throw; + } + } + + /// + /// Ensures exists with at least bytes. + /// Files smaller than the target are extended with zero bytes. Files larger are left alone. + /// + public static void EnsureFileLength(string path, long length) + { + using var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + if (fs.Length < length) + { + fs.SetLength(length); + } + } + + /// Truncates to bytes. + public static void Truncate(string path, long length) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + fs.SetLength(length); + } + + /// Creates the directory if it does not already exist (no-op when present). + public static void EnsureDirectory(string path) + { + Directory.CreateDirectory(path); + } + + /// Returns the OS memory-page size in bytes; useful for segment-size rounding. + public static int PageSize => Environment.SystemPageSize; + + /// Lists immediate subdirectory paths under . + public static IEnumerable EnumerateSlotDirectories(string root) + { + if (!Directory.Exists(root)) + { + return Array.Empty(); + } + + return Directory.EnumerateDirectories(root); + } + + /// Lists files in matching . + public static IEnumerable EnumerateFiles(string dir, string searchPattern) + { + if (!Directory.Exists(dir)) + { + return Array.Empty(); + } + + return Directory.EnumerateFiles(dir, searchPattern); + } + + /// Convenience for . + public static bool Exists(string path) => File.Exists(path); + + /// Convenience for , no-op if absent. + public static void Delete(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + /// True when running on a non-local filesystem we know would misbehave with SF. + /// + /// This is best-effort heuristics; we don't try to be exhaustive. SF documents itself as + /// "local filesystem only"; this method exists so callers can emit a warning when they + /// spot an obvious mistake (e.g. an NFS mount). + /// + public static bool LooksLikeNetworkPath(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // UNC paths: \\server\share\... + return path.StartsWith(@"\\", StringComparison.Ordinal); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return IsLinuxNetworkMount(path); + } + + return false; + } + + private static readonly string[] NetworkFsTypes = + { + "nfs", "nfs3", "nfs4", "cifs", "smbfs", "smb3", "smb2", "afpfs", "fuse.sshfs", "9p", + }; + + private static bool IsLinuxNetworkMount(string path) + { + string absolute; + try + { + absolute = Path.GetFullPath(path); + } + catch + { + return false; + } + + try + { + string? bestMatch = null; + string? bestFsType = null; + foreach (var line in File.ReadLines("/proc/mounts")) + { + var firstSpace = line.IndexOf(' '); + if (firstSpace < 0) continue; + var afterDevice = firstSpace + 1; + var secondSpace = line.IndexOf(' ', afterDevice); + if (secondSpace < 0) continue; + var thirdSpace = line.IndexOf(' ', secondSpace + 1); + if (thirdSpace < 0) continue; + + var mountPoint = UnescapeMountField(line.Substring(afterDevice, secondSpace - afterDevice)); + var fsType = line.Substring(secondSpace + 1, thirdSpace - secondSpace - 1); + + if (absolute == mountPoint || + absolute.StartsWith(mountPoint.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, + StringComparison.Ordinal)) + { + if (bestMatch is null || mountPoint.Length > bestMatch.Length) + { + bestMatch = mountPoint; + bestFsType = fsType; + } + } + } + + return bestFsType is not null && Array.IndexOf(NetworkFsTypes, bestFsType) >= 0; + } + catch + { + return false; + } + } + + private static string UnescapeMountField(string raw) + { + if (raw.IndexOf('\\') < 0) return raw; + var sb = new System.Text.StringBuilder(raw.Length); + for (var i = 0; i < raw.Length; i++) + { + if (raw[i] == '\\' && i + 3 < raw.Length + && raw[i + 1] is >= '0' and <= '7' + && raw[i + 2] is >= '0' and <= '7' + && raw[i + 3] is >= '0' and <= '7') + { + var oct = ((raw[i + 1] - '0') << 6) | ((raw[i + 2] - '0') << 3) | (raw[i + 3] - '0'); + sb.Append((char)oct); + i += 3; + } + else + { + sb.Append(raw[i]); + } + } + return sb.ToString(); + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpMemorySegment.cs b/src/net-questdb-client/Qwp/Sf/QwpMemorySegment.cs new file mode 100644 index 00000000..2117044e --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpMemorySegment.cs @@ -0,0 +1,282 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Runtime.InteropServices; + +namespace QuestDB.Qwp.Sf; + +/// +/// RAM-backed segment used when sf_dir is null. Same envelope format as +/// but skips the on-disk header and seal trailer — the segment +/// is never recovered, so persistence-only fields would be dead weight. +/// +internal sealed class QwpMemorySegment : IQwpSegment +{ + public const int EnvelopeHeaderSize = QwpMmapSegment.EnvelopeHeaderSize; + public const int DefaultMaxFrameLength = QwpMmapSegment.DefaultMaxFrameLength; + + // A single RAM-backed segment is malloc'd up front; cap it so an absurd configured capacity + // surfaces as a clear config error instead of an opaque NativeMemory.Alloc OOM. + public const long MaxCapacity = 4L * 1024 * 1024 * 1024; + + // Outstanding bytes held by live native segment buffers. Incremented when Allocate hands back a + // segment, decremented when its buffer is freed. A non-zero residual after a full GC means a + // segment was dropped without being freed — i.e. a native-memory leak. Test/diagnostic seam. + internal static long LiveNativeBytes; + + // Raw NativeMemory.Alloc'd buffer. The segment is a "dumb" resource: it frees its buffer in + // Dispose but carries no finalizer of its own. The holder (the ring it lives in) is responsible + // for disposing every segment on its own Dispose/finalizer, so a sender dropped without teardown + // does not leak. + private readonly unsafe byte* _basePtr; + private readonly long _capacity; + private readonly int _maxFrameLength; + private long[] _offsetTable; + private int _offsetTableCount; + private long _writePosition; + private bool _disposed; + + private unsafe QwpMemorySegment(byte* basePtr, long capacity, long baseFsn, int maxFrameLength) + { + _basePtr = basePtr; + _capacity = capacity; + BaseFsn = baseFsn; + _maxFrameLength = maxFrameLength; + _offsetTable = new long[16]; + _offsetTableCount = 0; + _writePosition = 0; + } + + public static unsafe QwpMemorySegment Allocate(long capacity, long baseFsn, int maxFrameLength = DefaultMaxFrameLength) + { + if (capacity <= EnvelopeHeaderSize) + { + throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must exceed envelope header size"); + } + + if (capacity > MaxCapacity) + { + throw new ArgumentOutOfRangeException(nameof(capacity), + $"capacity {capacity} exceeds the {MaxCapacity}-byte memory-segment cap"); + } + + var ptr = (byte*)NativeMemory.Alloc((nuint)capacity); + try + { + new Span(ptr, (int)Math.Min(capacity, int.MaxValue)).Clear(); + for (long off = int.MaxValue; off < capacity; off += int.MaxValue) + { + var n = (int)Math.Min(capacity - off, int.MaxValue); + new Span(ptr + off, n).Clear(); + } + var segment = new QwpMemorySegment(ptr, capacity, baseFsn, maxFrameLength); + Interlocked.Add(ref LiveNativeBytes, capacity); + return segment; + } + catch + { + NativeMemory.Free(ptr); + throw; + } + } + + public string Path => ""; + public long Capacity => _capacity; + public long BaseFsn { get; } + public long WritePosition => Volatile.Read(ref _writePosition); + public long NextFsn => BaseFsn + EnvelopeCount; + public long EnvelopeCount => Volatile.Read(ref _offsetTableCount); + public bool IsSealed { get; private set; } + + public unsafe bool TryAppend(ReadOnlySpan frame) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpMemorySegment)); + } + + if (IsSealed) + { + throw new InvalidOperationException("segment is sealed"); + } + + if (frame.Length == 0) + { + throw new ArgumentException("empty frames are not permitted on the wire", nameof(frame)); + } + + if (frame.Length > _maxFrameLength) + { + throw new ArgumentException( + $"frame length {frame.Length} exceeds the cap {_maxFrameLength}", nameof(frame)); + } + + var envelopeStart = WritePosition; + var totalSize = (long)EnvelopeHeaderSize + frame.Length; + if (envelopeStart + totalSize > _capacity) + { + return false; + } + + Span header = stackalloc byte[EnvelopeHeaderSize]; + BinaryPrimitives.WriteUInt32LittleEndian(header.Slice(4, 4), (uint)frame.Length); + var crcOverLen = QwpCrc32C.Compute(header.Slice(4, 4)); + var crc = QwpCrc32C.Compute(frame, crcOverLen); + BinaryPrimitives.WriteUInt32LittleEndian(header.Slice(0, 4), crc); + + WriteSpan(envelopeStart, header); + WriteSpan(envelopeStart + EnvelopeHeaderSize, frame); + + Volatile.Write(ref _writePosition, _writePosition + totalSize); + AppendOffset(envelopeStart); + return true; + } + + public int TryReadFrame(long offset, Span destination, out long envelopeFsn) + { + envelopeFsn = -1; + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpMemorySegment)); + } + + if (offset < 0 || offset >= WritePosition) + { + return -1; + } + + Span header = stackalloc byte[EnvelopeHeaderSize]; + ReadSpan(offset, header); + + var crc = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0, 4)); + var lenU = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(4, 4)); + if (lenU == 0 || lenU > (uint)_maxFrameLength) + { + return -1; + } + + var len = (int)lenU; + if (destination.Length < len) + { + throw new ArgumentException( + $"destination too small: need {len}, got {destination.Length}", nameof(destination)); + } + + ReadSpan(offset + EnvelopeHeaderSize, destination.Slice(0, len)); + + var crcOverLen = QwpCrc32C.Compute(header.Slice(4, 4)); + var actual = QwpCrc32C.Compute(destination.Slice(0, len), crcOverLen); + if (actual != crc) + { + throw new InvalidDataException( + $"memory segment: envelope at offset {offset} failed CRC verification"); + } + + envelopeFsn = OffsetToFsn(offset); + return len; + } + + public long? OffsetOfEnvelope(long envelopeIndex) + { + var count = Volatile.Read(ref _offsetTableCount); + if (envelopeIndex < 0 || envelopeIndex >= count) + { + return null; + } + + var table = Volatile.Read(ref _offsetTable); + return Volatile.Read(ref table[(int)envelopeIndex]); + } + + public void Seal() + { + IsSealed = true; + } + + public void Flush() + { + } + + public unsafe void Dispose() + { + if (_disposed) return; + _disposed = true; + NativeMemory.Free(_basePtr); + Interlocked.Add(ref LiveNativeBytes, -_capacity); + } + + private void AppendOffset(long offset) + { + var table = _offsetTable; + var count = _offsetTableCount; + if (count >= table.Length) + { + var grown = new long[table.Length * 2]; + Array.Copy(table, grown, count); + grown[count] = offset; + Volatile.Write(ref _offsetTable, grown); + Volatile.Write(ref _offsetTableCount, count + 1); + return; + } + + Volatile.Write(ref table[count], offset); + Volatile.Write(ref _offsetTableCount, count + 1); + } + + private long OffsetToFsn(long offset) + { + var count = Volatile.Read(ref _offsetTableCount); + var table = Volatile.Read(ref _offsetTable); + var idx = Array.BinarySearch(table, 0, count, offset); + if (idx < 0) idx = ~idx - 1; + if (idx < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), "offset precedes the first envelope"); + } + return BaseFsn + idx; + } + + private unsafe void WriteSpan(long offset, ReadOnlySpan bytes) + { + if ((ulong)offset + (ulong)bytes.Length > (ulong)_capacity) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + var dest = new Span(_basePtr + offset, bytes.Length); + bytes.CopyTo(dest); + } + + private unsafe void ReadSpan(long offset, Span bytes) + { + if ((ulong)offset + (ulong)bytes.Length > (ulong)_capacity) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + var src = new ReadOnlySpan(_basePtr + offset, bytes.Length); + src.CopyTo(bytes); + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpMmapSegment.cs b/src/net-questdb-client/Qwp/Sf/QwpMmapSegment.cs new file mode 100644 index 00000000..e701b7ff --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpMmapSegment.cs @@ -0,0 +1,721 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO.MemoryMappedFiles; +using Microsoft.Win32.SafeHandles; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// A single fixed-size, memory-mapped segment file holding back-to-back QWP frame envelopes. +/// +/// +/// File layout (all little-endian): +/// +/// offset size field +/// 0 4 magic = 0x31304653 ('SF01') +/// 4 1 version = 1 +/// 5 1 flags = 0 +/// 6 2 reserved = 0 +/// 8 8 baseSeq +/// 16 8 createdAtMicros +/// 24 .. envelope stream: [u32 crc32c | u32 frame_len | frame bytes] back-to-back +/// +/// The CRC covers frame_len + frame bytes. Replay walks envelopes from +/// and stops on a torn tail (oversized length, CRC mismatch, or +/// envelope crossing the segment boundary). +/// +/// The file is pre-extended to so writes never grow the file at +/// append time. Trailing zeros indicate "no envelope here yet". +/// +/// Thread safety. The segment is not internally synchronised. The owning +/// and serialise all access +/// to a given segment under _stateLock — producer's never overlaps +/// with reader's / on the same +/// instance. Don't use this class directly without that external serialisation; List<long> +/// for the offset table is not safe for concurrent mutation. +/// +internal sealed class EmptySegmentHeaderException : IOException +{ + public EmptySegmentHeaderException(string path) + : base($"segment {path}: header is empty (no SF01 written yet)") + { + } +} + +internal sealed class QwpMmapSegment : IQwpSegment +{ + public const int EnvelopeHeaderSize = 8; + public const int HeaderSize = 24; + public const uint FileMagic = 0x31304653; + public const byte FileVersion = 1; + // Frames are bounded only by their enclosing segment; CRC + length sanity catch torn tails. + public const int DefaultMaxFrameLength = int.MaxValue; + + private readonly MemoryMappedFile _mmap; + private readonly MemoryMappedViewAccessor _view; + private readonly FileStream _fileStream; + private readonly SafeMemoryMappedViewHandle _handle; + // Volatile-published immutable snapshot. Producer copy-on-grow; readers Volatile.Read. + private long[] _offsetTable; + private int _offsetTableCount; + private readonly unsafe byte* _basePtr; + private readonly long _viewSize; + private readonly int _maxFrameLength; + private bool _disposed; + + private unsafe QwpMmapSegment( + string path, + MemoryMappedFile mmap, + MemoryMappedViewAccessor view, + FileStream fileStream, + long capacity, + long baseFsn, + long writePosition, + List offsetTable, + int maxFrameLength) + { + Path = path; + _mmap = mmap; + _view = view; + _fileStream = fileStream; + _handle = view.SafeMemoryMappedViewHandle; + Capacity = capacity; + BaseFsn = baseFsn; + _writePosition = writePosition; + var initialCapacity = Math.Max(16, offsetTable.Count); + _offsetTable = new long[initialCapacity]; + for (var i = 0; i < offsetTable.Count; i++) _offsetTable[i] = offsetTable[i]; + _offsetTableCount = offsetTable.Count; + _maxFrameLength = maxFrameLength; + + byte* ptr = null; + _handle.AcquirePointer(ref ptr); + try + { + _basePtr = ptr + view.PointerOffset; + _viewSize = checked((long)_handle.ByteLength); + } + catch + { + _handle.ReleasePointer(); + throw; + } + } + + /// Filesystem path of the segment file. + public string Path { get; } + + /// Total mmap'd byte capacity. Fixed at construction. + public long Capacity { get; } + + /// FSN of the first envelope in this segment. + public long BaseFsn { get; } + + private long _writePosition; + + /// Byte offset where the next envelope will be written. + public long WritePosition => Volatile.Read(ref _writePosition); + + /// FSN of the next envelope (if appended). + public long NextFsn => BaseFsn + EnvelopeCount; + + /// Number of valid envelopes in the segment. + public long EnvelopeCount => Volatile.Read(ref _offsetTableCount); + + /// True when the segment cannot accept further appends (sealed by the manager). + public bool IsSealed { get; private set; } + + /// + /// Opens an existing segment file and replays it to find the last good write position. If + /// the file is fresh (zeroed), writes the SF01 header with the supplied . + /// If the file already has a valid SF01 header, validates magic+version and uses the on-disk + /// baseSeq (which must match ). + /// + /// If the on-disk header is corrupt or version-mismatched. + public static QwpMmapSegment Open( + string path, + long capacity, + long baseFsn, + int maxFrameLength = DefaultMaxFrameLength) + { + if (capacity <= HeaderSize + EnvelopeHeaderSize) + { + throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must be larger than the file + envelope header"); + } + + // Mapping only a prefix of an oversized on-disk segment would silently drop every envelope + // past `capacity` from replay (ScanForLastGoodEnvelope can't see them, and the ring's + // inter-segment FSN-gap guard never fires for a single segment). Fail loudly instead — this + // only happens when sf_max_total_bytes is lowered below the size of an existing segment. + var existing = new FileInfo(path); + if (existing.Exists && existing.Length > capacity) + { + throw new IngressError(ErrorCode.ConfigError, + $"SF segment `{System.IO.Path.GetFileName(path)}` is {existing.Length} bytes on disk, larger than the " + + $"configured segment capacity {capacity} bytes. Lowering sf_max_total_bytes below the size of an " + + "existing segment would truncate un-acked frames from replay. Restore the previous capacity or " + + "drain and remove the SF directory before lowering it."); + } + + var (mmap, fs) = QwpFiles.OpenMemoryMappedSegment(path, capacity); + MemoryMappedViewAccessor? view = null; + try + { + view = mmap.CreateViewAccessor(0, capacity, MemoryMappedFileAccess.ReadWrite); + + var onDiskBaseFsn = ReadOrInitHeader(view, path, baseFsn); + if (baseFsn >= 0 && onDiskBaseFsn != baseFsn) + { + throw new InvalidDataException( + $"segment {path}: on-disk baseSeq {onDiskBaseFsn} does not match expected {baseFsn}"); + } + + var (writePos, offsets) = ScanForLastGoodEnvelope(view, capacity, maxFrameLength); + + var tornByteCount = CountTornTailBytes(view, writePos, capacity); + if (tornByteCount > 0) + { + Trace.TraceWarning( + "QWP segment recovery: torn tail in {0} at offset {1} ({2} non-zero byte(s) in 8-byte boundary window); truncating.", + path, writePos, tornByteCount); + } + + ZeroViewRange(view, writePos, capacity - writePos); + + return new QwpMmapSegment(path, mmap, view, fs, capacity, onDiskBaseFsn, writePos, offsets, maxFrameLength); + } + catch (Exception) + { + view?.Dispose(); + mmap.Dispose(); + fs.Dispose(); + throw; + } + } + + /// Opens an existing segment, reading baseFsn from the header. Returns null if header is empty. + public static QwpMmapSegment? OpenExisting( + string path, + long capacity, + int maxFrameLength = DefaultMaxFrameLength) + { + try + { + return Open(path, capacity, baseFsn: -1, maxFrameLength); + } + catch (EmptySegmentHeaderException) + { + return null; + } + } + + + /// + /// Tries to append an envelope wrapping . Returns false if the + /// segment doesn't have room (caller should rotate to a new segment). + /// + /// If the segment is sealed. + public unsafe bool TryAppend(ReadOnlySpan frame) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpMmapSegment)); + } + + if (IsSealed) + { + throw new InvalidOperationException("segment is sealed"); + } + + if (frame.Length == 0) + { + throw new ArgumentException("empty frames are not permitted on the wire", nameof(frame)); + } + + if (frame.Length > _maxFrameLength) + { + // Replay would truncate this as a torn tail on next reopen. + throw new ArgumentException( + $"frame length {frame.Length} exceeds the replay cap {_maxFrameLength}", + nameof(frame)); + } + + var envelopeStart = WritePosition; + var totalSize = (long)EnvelopeHeaderSize + frame.Length; + if (envelopeStart + totalSize > Capacity) + { + return false; + } + + // Layout: [crc(4)][len(4)][frame...]. CRC covers len+frame. + // CRC is stamped last so any interrupted write is detected as a torn tail on reopen. + Span u32 = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(u32, (uint)frame.Length); + WriteSpan(envelopeStart + 4, u32); + WriteSpan(envelopeStart + EnvelopeHeaderSize, frame); + + var crc = QwpCrc32C.Compute(u32); + crc = QwpCrc32C.Compute(frame, crc); + BinaryPrimitives.WriteUInt32LittleEndian(u32, crc); + WriteSpan(envelopeStart, u32); + + Volatile.Write(ref _writePosition, _writePosition + totalSize); + AppendOffset(envelopeStart); + return true; + } + + private void AppendOffset(long offset) + { + var table = _offsetTable; + var count = _offsetTableCount; + if (count >= table.Length) + { + var grown = new long[table.Length * 2]; + Array.Copy(table, grown, count); + grown[count] = offset; + Volatile.Write(ref _offsetTable, grown); + Volatile.Write(ref _offsetTableCount, count + 1); + return; + } + + Volatile.Write(ref table[count], offset); + Volatile.Write(ref _offsetTableCount, count + 1); + } + + /// + /// Reads the envelope at into . + /// + /// + /// Number of frame bytes copied into , or -1 if the + /// offset is past the last valid envelope. + /// + /// If is too small. + /// If the envelope CRC fails to verify on-disk content. + public int TryReadFrame(long offset, Span destination, out long envelopeFsn) + { + envelopeFsn = -1; + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpMmapSegment)); + } + + if (offset < 0 || offset >= WritePosition) + { + return -1; + } + + Span header = stackalloc byte[EnvelopeHeaderSize]; + ReadSpan(offset, header); + + var crc = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0, 4)); + var lenU = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(4, 4)); + if (lenU == 0 || lenU > (uint)_maxFrameLength) + { + return -1; + } + + var len = (int)lenU; + if (destination.Length < len) + { + throw new ArgumentException( + $"destination too small: need {len}, got {destination.Length}", nameof(destination)); + } + + ReadSpan(offset + EnvelopeHeaderSize, destination.Slice(0, len)); + + // Verify per read: mmap pages can corrupt out-of-band after the Open() replay. + var crcOverLen = QwpCrc32C.Compute(header.Slice(4, 4)); + var actual = QwpCrc32C.Compute(destination.Slice(0, len), crcOverLen); + if (actual != crc) + { + throw new InvalidDataException( + $"segment {Path}: envelope at offset {offset} failed CRC verification " + + $"(expected 0x{crc:x8}, got 0x{actual:x8})"); + } + + envelopeFsn = OffsetToFsn(offset); + return len; + } + + /// + /// Returns the byte offset of envelope (0-based within + /// this segment). O(1) — backed by the offset table. + /// + public long? OffsetOfEnvelope(long envelopeIndex) + { + var count = Volatile.Read(ref _offsetTableCount); + if (envelopeIndex < 0 || envelopeIndex >= count) + { + return null; + } + + var table = Volatile.Read(ref _offsetTable); + return Volatile.Read(ref table[(int)envelopeIndex]); + } + + /// Marks the segment as no longer accepting appends and flushes it to disk. + public void Seal() + { + if (IsSealed) + { + return; + } + + IsSealed = true; + Flush(); + } + + /// Forces dirty pages to be written to disk. + public void Flush() + { + if (_disposed) + { + return; + } + + _view.Flush(); + _fileStream.Flush(flushToDisk: true); + } + + /// + /// Disposes the view and underlying mmap handle. A failed flush propagates as + /// after teardown completes — SF's data-on-disk promise depends + /// on observing msync failures rather than swallowing them. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + GC.SuppressFinalize(this); + + Exception? flushError = null; + try + { + _view.Flush(); + _fileStream.Flush(flushToDisk: true); + } + catch (Exception ex) + { + flushError = ex; + } + + ReleaseUnmanaged(); + _view.Dispose(); + _mmap.Dispose(); + SfCleanup.Dispose(_fileStream); + + if (flushError is not null) + { + throw flushError; + } + } + + // Backstop for an instance dropped without Dispose: releases the native pointer pin only. + // Managed members (_view/_mmap/_fileStream — themselves SafeHandle-backed) may already be + // finalized, so they are deliberately not touched here. + ~QwpMmapSegment() + { + if (_disposed) + { + return; + } + + _disposed = true; + ReleaseUnmanaged(); + } + + // Idempotent: ReleasePointer is balanced against the single AcquirePointer in the ctor. + private void ReleaseUnmanaged() + { + SfCleanup.Run(() => _handle.ReleasePointer()); + } + + /// + /// Public test seam: replays the mmap and returns the last good offset and the table of + /// envelope start offsets. Every envelope's CRC is verified; the walk stops at the first + /// torn tail (zero/oversized length, declared length past EOF, or CRC mismatch). + /// + internal static unsafe (long WritePosition, List Offsets) ScanForLastGoodEnvelope( + MemoryMappedViewAccessor view, + long capacity, + int maxFrameLength = DefaultMaxFrameLength) + { + long offset = HeaderSize; + var offsets = new List(); + + var handle = view.SafeMemoryMappedViewHandle; + byte* basePtr = null; + handle.AcquirePointer(ref basePtr); + try + { + while (offset + EnvelopeHeaderSize <= capacity) + { + var header = new ReadOnlySpan(basePtr + offset, EnvelopeHeaderSize); + + var crc = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0, 4)); + var lenU = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(4, 4)); + + if (lenU == 0 && crc == 0) + { + break; + } + + if (lenU == 0 || lenU > (uint)maxFrameLength) + { + break; + } + + var len = (int)lenU; + if (offset + EnvelopeHeaderSize + len > capacity) + { + break; + } + + var frame = new ReadOnlySpan(basePtr + offset + EnvelopeHeaderSize, len); + var crcOverLen = QwpCrc32C.Compute(header.Slice(4, 4)); + var actual = QwpCrc32C.Compute(frame, crcOverLen); + if (actual != crc) + { + break; + } + + offsets.Add(offset); + offset += EnvelopeHeaderSize + len; + } + } + finally + { + handle.ReleasePointer(); + } + + return (offset, offsets); + } + + private static unsafe int CountTornTailBytes(MemoryMappedViewAccessor view, long offset, long capacity) + { + var window = (int)Math.Min(8, capacity - offset); + if (window <= 0) return 0; + + var handle = view.SafeMemoryMappedViewHandle; + byte* basePtr = null; + handle.AcquirePointer(ref basePtr); + try + { + var count = 0; + for (var i = 0; i < window; i++) + { + if (basePtr[offset + i] != 0) count++; + } + return count; + } + finally + { + handle.ReleasePointer(); + } + } + + private static long ReadOrInitHeader( + MemoryMappedViewAccessor view, string path, long baseFsn) + { + Span hdr = stackalloc byte[HeaderSize]; + ViewToSpan(view, 0, hdr); + + var magic = BinaryPrimitives.ReadUInt32LittleEndian(hdr.Slice(0, 4)); + if (magic == 0) + { + var allZero = true; + for (var i = 4; i < HeaderSize; i++) + { + if (hdr[i] != 0) { allZero = false; break; } + } + if (!allZero) + { + throw new InvalidDataException($"segment {path}: missing SF01 magic but header bytes are non-zero"); + } + if (baseFsn < 0) + { + throw new EmptySegmentHeaderException(path); + } + WriteHeader(view, baseFsn, NowMicros()); + return baseFsn; + } + + if (magic != FileMagic) + { + throw new InvalidDataException( + $"segment {path}: bad magic 0x{magic:x8}, expected 0x{FileMagic:x8} ('SF01')"); + } + + var version = hdr[4]; + if (version != FileVersion) + { + throw new InvalidDataException($"segment {path}: unsupported version {version}"); + } + + var readBaseFsn = BinaryPrimitives.ReadInt64LittleEndian(hdr.Slice(8, 8)); + if (readBaseFsn < 0) + { + throw new InvalidDataException($"segment {path}: bad baseFsn {readBaseFsn}"); + } + return readBaseFsn; + } + + private static void WriteHeader(MemoryMappedViewAccessor view, long baseFsn, long createdAtMicros) + { + Span hdr = stackalloc byte[HeaderSize]; + BinaryPrimitives.WriteUInt32LittleEndian(hdr.Slice(0, 4), FileMagic); + hdr[4] = FileVersion; + hdr[5] = 0; + BinaryPrimitives.WriteUInt16LittleEndian(hdr.Slice(6, 2), 0); + BinaryPrimitives.WriteInt64LittleEndian(hdr.Slice(8, 8), baseFsn); + BinaryPrimitives.WriteInt64LittleEndian(hdr.Slice(16, 8), createdAtMicros); + WriteToView(view, 0, hdr); + } + + private static long NowMicros() + { + const long unixEpochTicks = 621355968000000000L; + return (DateTime.UtcNow.Ticks - unixEpochTicks) / 10L; + } + + /// + /// Returns the FSN at the given offset using the offset table. O(log N) via binary search. + /// Used by replay paths that resolve an offset back to an FSN. + /// + private long OffsetToFsn(long offset) + { + var count = Volatile.Read(ref _offsetTableCount); + var table = Volatile.Read(ref _offsetTable); + var idx = Array.BinarySearch(table, 0, count, offset); + if (idx < 0) + { + // Offset doesn't sit on an envelope boundary; the bit-flip equivalent is the insertion + // point. Treat the preceding envelope as the FSN. + idx = ~idx - 1; + } + + if (idx < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), "offset precedes the first envelope"); + } + + return BaseFsn + idx; + } + + private unsafe void WriteSpan(long offset, ReadOnlySpan bytes) + { + if ((ulong)offset + (ulong)bytes.Length > (ulong)_viewSize) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + var dest = new Span(_basePtr + offset, bytes.Length); + bytes.CopyTo(dest); + } + + private unsafe void ReadSpan(long offset, Span bytes) + { + if ((ulong)offset + (ulong)bytes.Length > (ulong)_viewSize) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + var src = new ReadOnlySpan(_basePtr + offset, bytes.Length); + src.CopyTo(bytes); + } + + /// + /// Static span access used by at construction time — + /// before exists, so no instance pointer is available. + /// + private static unsafe void ViewToSpan(MemoryMappedViewAccessor view, long offset, Span dest) + { + var handle = view.SafeMemoryMappedViewHandle; + byte* ptr = null; + handle.AcquirePointer(ref ptr); + try + { + var src = new ReadOnlySpan(ptr + view.PointerOffset + offset, dest.Length); + src.CopyTo(dest); + } + finally + { + handle.ReleasePointer(); + } + } + + private static unsafe void WriteToView(MemoryMappedViewAccessor view, long offset, ReadOnlySpan src) + { + var handle = view.SafeMemoryMappedViewHandle; + byte* ptr = null; + handle.AcquirePointer(ref ptr); + try + { + var dest = new Span(ptr + view.PointerOffset + offset, src.Length); + src.CopyTo(dest); + } + finally + { + handle.ReleasePointer(); + } + } + + private static unsafe void ZeroViewRange(MemoryMappedViewAccessor view, long offset, long length) + { + if (length <= 0) + { + return; + } + + var handle = view.SafeMemoryMappedViewHandle; + byte* ptr = null; + handle.AcquirePointer(ref ptr); + try + { + var dest = ptr + view.PointerOffset + offset; + // Loop in int.MaxValue chunks so Span can hold the slice. + var remaining = length; + while (remaining > 0) + { + var n = (int)Math.Min(remaining, int.MaxValue); + new Span(dest, n).Clear(); + dest += n; + remaining -= n; + } + } + finally + { + handle.ReleasePointer(); + } + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpOrphanScanner.cs b/src/net-questdb-client/Qwp/Sf/QwpOrphanScanner.cs new file mode 100644 index 00000000..70884a4f --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpOrphanScanner.cs @@ -0,0 +1,144 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Sf; + +/// +/// Discovers sibling slot directories left behind by crashed senders and claims their locks. +/// +/// +/// The scanner only runs when the sender is configured with drain_orphans=on. For each +/// sibling slot under <sf_dir>/*/ it: +/// +/// skips our own slot (matched by sender_id); +/// skips slots carrying a .failed sentinel — a previous drain has surrendered; +/// tries the slot lock with — if another live +/// sender or drainer holds it, we leave it alone; +/// discards empty slots (no sf-*.sfa segment files) — nothing to drain. +/// +/// The remaining locks are returned to the caller, which hands them to a +/// . Lock ownership transfers with the return value; +/// the caller must dispose any locks it does not enqueue. +/// +internal static class QwpOrphanScanner +{ + private const string FailedSentinel = ".failed"; + private const string SegmentGlob = "sf-*.sfa"; + + /// + /// Walks and returns locks for every sibling slot eligible for + /// orphan drain. + /// + /// The shared store-and-forward root directory. + /// + /// The current sender's slot name, never claimed by the scanner. + /// + public static IReadOnlyList ClaimOrphans(string sfRoot, string ourSenderId) + { + ArgumentNullException.ThrowIfNull(sfRoot); + ArgumentNullException.ThrowIfNull(ourSenderId); + + if (!Directory.Exists(sfRoot)) + { + return Array.Empty(); + } + + List slotDirs; + try + { + slotDirs = QwpFiles.EnumerateSlotDirectories(sfRoot).ToList(); + } + catch (Exception) + { + return Array.Empty(); + } + + var claimed = new List(slotDirs.Count); + + foreach (var slotDir in slotDirs) + { + try + { + TryClaim(slotDir, ourSenderId, claimed); + } + catch (Exception) + { + // Per-slot errors must never abandon already-claimed locks; the next sweep retries. + } + } + + return claimed; + } + + private static void TryClaim(string slotDir, string ourSenderId, List claimed) + { + var senderId = new DirectoryInfo(slotDir).Name; + if (string.Equals(senderId, ourSenderId, StringComparison.Ordinal)) + { + return; + } + + if (File.Exists(Path.Combine(slotDir, FailedSentinel))) + { + return; + } + + var slotLock = QwpSlotLock.TryAcquire(slotDir); + if (slotLock is null) + { + return; + } + + var keep = false; + try + { + if (File.Exists(Path.Combine(slotDir, FailedSentinel))) + { + return; + } + + if (!HasSegments(slotDir)) + { + return; + } + + claimed.Add(slotLock); + keep = true; + } + finally + { + if (!keep) slotLock.Dispose(); + } + } + + private static bool HasSegments(string slotDir) + { + foreach (var _ in QwpFiles.EnumerateFiles(slotDir, SegmentGlob)) + { + return true; + } + + return false; + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpReconnectPolicy.cs b/src/net-questdb-client/Qwp/Sf/QwpReconnectPolicy.cs new file mode 100644 index 00000000..078120bd --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpReconnectPolicy.cs @@ -0,0 +1,173 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Sf; + +/// +/// Pure-function exponential-backoff math used by the SF reconnect loop and the egress query +/// client's failover loop. +/// +/// +/// The policy doesn't perform any I/O or sleeping itself; the caller passes in elapsed times +/// and the policy returns the next backoff or signals "give up". This keeps the loop logic +/// testable in isolation from the wall clock. +/// +/// Backoff schedule: start at , double each attempt, cap at +/// . Per-outage budget bounds total +/// wait time across the entire reconnect run. +/// +internal sealed class QwpReconnectPolicy +{ + private readonly Func _jitter; + + /// Wait before the first reconnect attempt. + /// Cap on per-attempt wait — exponential growth saturates here. + /// Total time budget across all attempts in one outage; engine becomes terminal once exceeded. + /// + /// Optional jitter transform applied after exponential growth and max-clamping. Pass + /// to spread backoff uniformly over [base, 2·base). + /// Default: identity (deterministic — used by tests). + /// + public QwpReconnectPolicy( + TimeSpan initialBackoff, + TimeSpan maxBackoff, + TimeSpan maxOutageDuration, + Func? jitter = null) + { + if (initialBackoff <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(initialBackoff), "must be positive"); + } + + if (maxBackoff < initialBackoff) + { + throw new ArgumentOutOfRangeException(nameof(maxBackoff), "must be ≥ initialBackoff"); + } + + if (maxOutageDuration < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(maxOutageDuration), "must be ≥ 0"); + } + + InitialBackoff = initialBackoff; + MaxBackoff = maxBackoff; + MaxOutageDuration = maxOutageDuration; + _jitter = jitter ?? (b => b); + } + + /// Backoff for the first reconnect attempt. + public TimeSpan InitialBackoff { get; } + + /// Upper bound on any single backoff after exponential growth. + public TimeSpan MaxBackoff { get; } + + /// Total wall-clock wait budget across the whole reconnect run. + public TimeSpan MaxOutageDuration { get; } + + /// + /// Equal-jitter transform — uniform in [base, 2·base). Used by the SF + /// reconnect loop where multiple producers may share a cluster and the lower + /// bound damps reconnect storms. + /// + public static TimeSpan EqualJitter(TimeSpan baseBackoff) + { + if (baseBackoff <= TimeSpan.Zero) + { + return baseBackoff; + } + + var add = (long)(Random.Shared.NextDouble() * baseBackoff.Ticks); + return TimeSpan.FromTicks(baseBackoff.Ticks + add); + } + + /// + /// Full-jitter transform — uniform in [0, base). Used by the egress + /// per-Execute failover loop where a single user benefits from the lowest + /// expected recovery time. + /// + public static TimeSpan FullJitter(TimeSpan baseBackoff) + { + if (baseBackoff <= TimeSpan.Zero) + { + return baseBackoff; + } + + var ticks = (long)(Random.Shared.NextDouble() * baseBackoff.Ticks); + return TimeSpan.FromTicks(ticks); + } + + /// + /// Computes the backoff for attempt (0-based). Doubling + /// starts at , is clamped to , and + /// finally passed through the configured jitter transform. + /// + public TimeSpan ComputeBackoff(int attemptIndex) + { + if (attemptIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(attemptIndex)); + } + + // Saturate on ticks to avoid long overflow when InitialBackoff is days-scale. + var ticks = InitialBackoff.Ticks; + var maxTicks = MaxBackoff.Ticks; + for (var i = 0; i < attemptIndex && ticks < maxTicks; i++) + { + if (ticks > maxTicks / 2) + { + ticks = maxTicks; + break; + } + ticks <<= 1; + } + + var clampedTicks = ticks > maxTicks ? maxTicks : ticks; + // Cap base before jitter; the post-jitter sleep can land in [base, 2·base). + return _jitter(TimeSpan.FromTicks(clampedTicks)); + } + + /// + /// Returns the next backoff to sleep for, or null if the per-outage budget is exhausted. + /// + /// 0 for the first reconnect, 1 for the second, etc. + /// Total wall-clock elapsed since the connection failed. + public TimeSpan? NextBackoffOrGiveUp(int attemptIndex, TimeSpan elapsedSinceOutage) + { + if (elapsedSinceOutage > MaxOutageDuration) + { + return null; + } + + var backoff = ComputeBackoff(attemptIndex); + + // Don't sleep past the outage budget — clip to whatever remains. + var remaining = MaxOutageDuration - elapsedSinceOutage; + if (backoff > remaining) + { + return remaining > TimeSpan.Zero ? remaining : null; + } + + return backoff; + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpSegmentManager.cs b/src/net-questdb-client/Qwp/Sf/QwpSegmentManager.cs new file mode 100644 index 00000000..a1d42e10 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSegmentManager.cs @@ -0,0 +1,323 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Sf; + +/// +/// Background worker that owns hot-spare provisioning and trim for one +/// . Producer and I/O receive-pump never touch the disk for +/// spare creation, file deletion or mmap teardown; they only signal the manager via the +/// wakeup callback registered through . +/// +internal sealed class QwpSegmentManager : IDisposable +{ + public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(1); + public static readonly TimeSpan DefaultShutdownWait = TimeSpan.FromSeconds(5); + + private readonly QwpSegmentRing _ring; + private readonly long _maxTotalBytes; + private readonly TimeSpan _shutdownWait; + private readonly TimeSpan _heartbeatInterval; + private readonly SemaphoreSlim _wakeup = new(0, 1); + private readonly CancellationTokenSource _cts = new(); + + private Task? _workerTask; + private long _committedBytes; + private volatile bool _disposed; + private Exception? _lastServiceError; + private Action? _heartbeatCallback; + private QwpAckWatermark? _ackWatermark; + private long _lastPersistedAck = long.MinValue; + + public QwpSegmentManager( + QwpSegmentRing ring, + long maxTotalBytes, + TimeSpan? shutdownWait = null, + TimeSpan? heartbeatInterval = null) + { + try + { + _ring = ring ?? throw new ArgumentNullException(nameof(ring)); + if (maxTotalBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxTotalBytes), "must be > 0"); + } + + _maxTotalBytes = maxTotalBytes; + _shutdownWait = shutdownWait ?? DefaultShutdownWait; + _heartbeatInterval = heartbeatInterval ?? DefaultHeartbeatInterval; + _committedBytes = ring.TotalCapacityBytes; + ring.SetMaxTotalBytes(maxTotalBytes); + } + catch + { + _wakeup.Dispose(); + _cts.Dispose(); + throw; + } + } + + public long CommittedBytes => Volatile.Read(ref _committedBytes); + public long MaxTotalBytes => _maxTotalBytes; + public TimeSpan HeartbeatInterval => _heartbeatInterval; + + public void SetHeartbeatCallback(Action? callback) + { + Volatile.Write(ref _heartbeatCallback, callback); + } + + public void SetAckWatermark(QwpAckWatermark? watermark) + { + Volatile.Write(ref _ackWatermark, watermark); + } + internal long TrimCycles { get; private set; } + internal long SparesInstalled { get; private set; } + + public void Start() + { + if (_workerTask is not null) + { + throw new InvalidOperationException("manager already started"); + } + + if (_disposed) + { + throw new ObjectDisposedException(nameof(QwpSegmentManager)); + } + + _ring.SetManagerWakeup(Wake); + // Adoption failure means the spare's bytes are gone from disk; decrement explicitly so the + // next provisioning gate doesn't see a phantom commit and refuse a replacement. + _ring.SetSpareAdoptionFailedCallback(OnSpareAdoptionFailed); + _workerTask = Task.Run(() => RunAsync(_cts.Token)); + } + + public void Wake() + { + try + { + _wakeup.Release(); + } + catch (SemaphoreFullException) + { + } + catch (ObjectDisposedException) + { + // Ring callbacks aren't unregistered on Dispose; ignoring late wakes is safe. + } + } + + private void OnSpareAdoptionFailed() + { + Interlocked.Add(ref _committedBytes, -_ring.SegmentCapacity); + Wake(); + } + + internal Task? WorkerTask => _workerTask; + + internal void RequestShutdown() + { + SfCleanup.Run(() => _cts.Cancel()); + SfCleanup.Run(() => _wakeup.Release()); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + RequestShutdown(); + + if (_workerTask is not null) + { + SfCleanup.Run(() => _workerTask.Wait(_shutdownWait)); + } + + SfCleanup.Dispose(_cts); + SfCleanup.Dispose(_wakeup); + } + + private async Task RunAsync(CancellationToken ct) + { + while (!_disposed && !ct.IsCancellationRequested) + { + try + { + ServiceRing(); + } + catch (Exception ex) + { + Volatile.Write(ref _lastServiceError, ex); + } + + try + { + await _wakeup.WaitAsync(_heartbeatInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + } + + try + { + ServiceRing(); + } + catch (Exception ex) + { + Volatile.Write(ref _lastServiceError, ex); + } + } + + /// Last unexpected exception thrown by ServiceRing; null when never faulted. + public Exception? LastServiceError => Volatile.Read(ref _lastServiceError); + + private void ServiceRing() + { + // Snapshot under-counts during the producer's adopt window (hotSparePath cleared before + // active is published); never let it shrink committed, otherwise the gate below would + // re-provision and breach maxTotalBytes. Decrements come from explicit trim and + // OnSpareAdoptionFailed. + var (capacity, hasSpare) = _ring.SnapshotCapacity(); + var snapshot = capacity + (hasSpare ? _ring.SegmentCapacity : 0); + // CAS loop: a concurrent Interlocked.Add from OnSpareAdoptionFailed must not be lost + // to a non-atomic read-modify-write here. + long committed; + while (true) + { + var prev = Volatile.Read(ref _committedBytes); + committed = snapshot > prev ? snapshot : prev; + if (Interlocked.CompareExchange(ref _committedBytes, committed, prev) == prev) + { + break; + } + } + + if (_ring.NeedsHotSpare()) + { + if (committed + _ring.SegmentCapacity <= _maxTotalBytes) + { + ProvisionHotSpare(); + } + } + + DrainAndDisposeTrimmable(); + _ring.FlushActive(); + // Persist the watermark only after segment data is flushed so the persisted point + // can only under-estimate (never claim more durable than is actually on disk). + PersistAckWatermark(); + try { Volatile.Read(ref _heartbeatCallback)?.Invoke(); } + catch (Exception ex) { Volatile.Write(ref _lastServiceError, ex); } + } + + private void PersistAckWatermark() + { + var watermark = Volatile.Read(ref _ackWatermark); + if (watermark is null) return; + var currentAck = _ring.AckedFsn; + if (currentAck > _lastPersistedAck) + { + watermark.Write(currentAck); + watermark.Flush(); + _lastPersistedAck = currentAck; + } + } + + private void ProvisionHotSpare() + { + var directory = _ring.Directory; + if (directory is null) return; + var sparePath = Path.Combine( + directory, + QwpSegmentRing.SparePrefix + Guid.NewGuid().ToString("N") + QwpSegmentRing.SpareSuffix); + var capacity = _ring.SegmentCapacity; + + try + { + using var fs = QwpFiles.OpenExclusive(sparePath); + // Force block allocation so a producer mmap-write can't trigger SIGBUS / EFAULT later + // when the disk turns out to be full. Reserve does the SetLength itself, and must see the + // still-zero-length file first: macOS F_PREALLOCATE reserves from the physical EOF, so a + // SetLength here would make toReserve==0 and leave a sparse spare that SIGBUSes on write. + QwpFallocate.Reserve(fs, capacity); + fs.Flush(flushToDisk: true); + } + catch (Exception ex) + { + Volatile.Write(ref _lastServiceError, ex); + SfCleanup.DeleteFile(sparePath); + return; + } + + if (_ring.InstallHotSpare(sparePath)) + { + Interlocked.Add(ref _committedBytes, capacity); + SparesInstalled++; + } + else + { + SfCleanup.DeleteFile(sparePath); + } + } + + private void DrainAndDisposeTrimmable() + { + var trim = _ring.DrainTrimmable(); + if (trim is null) + { + return; + } + + var memoryBacked = _ring.IsMemoryBacked; + long freed = 0; + for (var i = 0; i < trim.Count; i++) + { + var seg = trim[i]; + var path = seg.Path; + var size = seg.Capacity; + SfCleanup.Dispose(seg); + if (!memoryBacked) + { + // File-mode: unlink failure leaves the file for next sender startup recovery. + SfCleanup.DeleteFile(path); + } + freed += size; + } + + if (freed > 0) + { + Interlocked.Add(ref _committedBytes, -freed); + } + + TrimCycles++; + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpSegmentRing.cs b/src/net-questdb-client/Qwp/Sf/QwpSegmentRing.cs new file mode 100644 index 00000000..a368cf78 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSegmentRing.cs @@ -0,0 +1,777 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Diagnostics; +using System.Globalization; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// Chained ring of files in one directory. Three writers: +/// producer ( + sealed-list add on rotation), engine receive-pump +/// (), and the manager ( + +/// ). Hot path is lock-free; the lock guards sealed-list +/// mutations and cross-thread iteration. +/// +internal sealed class QwpSegmentRing : IDisposable +{ + private const string FilenamePrefix = "sf-"; + private const string FilenameSuffix = ".sfa"; + internal const string SparePrefix = "sf-spare-"; + internal const string SpareSuffix = ".tmp"; + + private readonly string? _directory; + private readonly long _segmentCapacity; + private readonly int _maxFrameLength; + private readonly long _highWaterTrigger; + private readonly object _lock = new(); + private readonly List _sealedSegments = new(); + + private IQwpSegment? _active; + private string? _hotSparePath; + private long _maxTotalBytes = long.MaxValue; + private long _publishedFsn; + private long _ackedFsn; + private long _fileGeneration = -1L; + private Action? _managerWakeup; + private Action? _spareInstalledCallback; + private Action? _spareAdoptionFailed; + private bool _wakeRequestedForActive; + private volatile bool _closed; + // Debug-only producer-in-flight counter: a non-zero value during Dispose means TryAppend is + // running concurrently with disposal, which can free a segment out from under the producer. + private int _appendsInFlight; + + private QwpSegmentRing(string? directory, long segmentCapacity, int maxFrameLength) + { + _directory = directory; + _segmentCapacity = segmentCapacity; + _maxFrameLength = maxFrameLength; + // 75%: leaves a quarter-segment of producer runway for the manager to provision a spare. + _highWaterTrigger = (segmentCapacity >> 2) * 3; + _publishedFsn = -1L; + _ackedFsn = -1L; + } + + public bool IsMemoryBacked => _directory is null; + + /// + /// Memory-mode cap: producer's TryAppend returns false when allocating another segment + /// would exceed this. File-mode rings ignore this hint (the segment manager enforces the + /// cap via its hot-spare gate). Must be called before . + /// + public void SetMaxTotalBytes(long maxTotalBytes) + { + if (maxTotalBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxTotalBytes)); + } + Volatile.Write(ref _maxTotalBytes, maxTotalBytes); + } + + public long PublishedFsn => Volatile.Read(ref _publishedFsn); + public long AckedFsn => Volatile.Read(ref _ackedFsn); + public long NextFsn => PublishedFsn + 1; + + public long OldestFsn + { + get + { + lock (_lock) + { + if (_sealedSegments.Count > 0) + { + return _sealedSegments[0].BaseFsn; + } + + return Volatile.Read(ref _active)?.BaseFsn ?? 0L; + } + } + } + + public long SegmentCapacity => _segmentCapacity; + public int MaxFrameLength => _maxFrameLength; + public string? Directory => _directory; + + public int SegmentCount + { + get + { + lock (_lock) + { + return _sealedSegments.Count + (Volatile.Read(ref _active) is null ? 0 : 1); + } + } + } + + public long TotalCapacityBytes + { + get + { + lock (_lock) + { + long total = 0; + for (var i = 0; i < _sealedSegments.Count; i++) + { + total += _sealedSegments[i].Capacity; + } + var active = Volatile.Read(ref _active); + if (active is not null) + { + total += active.Capacity; + } + return total; + } + } + } + + public bool NeedsHotSpare() + { + if (IsMemoryBacked) return false; + if (Volatile.Read(ref _hotSparePath) is not null) return false; + var active = Volatile.Read(ref _active); + if (active is null) return true; + if (active.IsSealed) return true; + return active.WritePosition >= _highWaterTrigger; + } + + /// True iff a hot-spare path is installed and not yet adopted (file mode only). + public bool HasHotSpare => Volatile.Read(ref _hotSparePath) is not null; + + /// Atomic read of (TotalCapacityBytes, HasHotSpare) — both under the same lock. + public (long TotalCapacityBytes, bool HasHotSpare) SnapshotCapacity() + { + lock (_lock) + { + long total = 0; + for (var i = 0; i < _sealedSegments.Count; i++) + { + total += _sealedSegments[i].Capacity; + } + var active = Volatile.Read(ref _active); + if (active is not null) + { + total += active.Capacity; + } + return (total, _hotSparePath is not null); + } + } + + public static QwpSegmentRing Open( + string directory, + long segmentCapacity = 64L * 1024 * 1024, + int maxFrameLength = QwpMmapSegment.DefaultMaxFrameLength) + { + QwpFiles.EnsureDirectory(directory); + var ring = new QwpSegmentRing(directory, segmentCapacity, maxFrameLength); + ring._fileGeneration = ScanMaxGeneration(directory); + + try + { + CleanupStaleSpares(directory); + + // Filename is enumerate-only; ordering comes from the on-disk header baseSeq. + var opened = new List(); + try + { + foreach (var path in QwpFiles.EnumerateFiles(directory, FilenamePrefix + "*" + FilenameSuffix)) + { + if (Path.GetFileName(path).StartsWith(SparePrefix, StringComparison.Ordinal)) + { + continue; + } + + QwpMmapSegment? seg; + try + { + seg = QwpMmapSegment.OpenExisting(path, segmentCapacity, maxFrameLength); + } + catch (InvalidDataException) + { + SfCleanup.RenameFileToCorrupt(path); + continue; + } + if (seg is null) + { + SfCleanup.DeleteFile(path); + continue; + } + opened.Add(seg); + } + + opened.Sort((a, b) => a.BaseFsn.CompareTo(b.BaseFsn)); + + // FSN gap = corruption (deleted mid-segment, partial restore). Fail at startup + // rather than letting the cursor walk off the ring later. + for (var i = 1; i < opened.Count; i++) + { + var prev = opened[i - 1]; + var curr = opened[i]; + var expected = prev.BaseFsn + prev.EnvelopeCount; + if (curr.BaseFsn != expected) + { + throw new IngressError(ErrorCode.ConfigError, + $"SF segment FSN gap at `{System.IO.Path.GetFileName(curr.Path)}`: " + + $"expected baseFsn={expected}, got {curr.BaseFsn}"); + } + } + + for (var i = 0; i < opened.Count; i++) + { + var seg = opened[i]; + if (i < opened.Count - 1) + { + seg.Seal(); + ring._sealedSegments.Add(seg); + } + else + { + Volatile.Write(ref ring._active, seg); + } + } + } + catch + { + foreach (var s in opened) SfCleanup.Dispose(s); + throw; + } + + var lastRecovered = Volatile.Read(ref ring._active) + ?? (ring._sealedSegments.Count > 0 ? ring._sealedSegments[^1] : null); + ring._publishedFsn = lastRecovered is null + ? -1L + : lastRecovered.BaseFsn + lastRecovered.EnvelopeCount - 1; + + return ring; + } + catch (Exception) + { + ring.Dispose(); + throw; + } + } + + /// + /// Opens a memory-backed ring (sf_dir == null). No persistence, no recovery, no slot lock. + /// Segments are allocated lazily by the producer; the manager still manages the total-bytes + /// cap and trims acked segments by freeing their native memory. + /// + public static QwpSegmentRing OpenMemoryBacked( + long segmentCapacity = 4L * 1024 * 1024, + int maxFrameLength = QwpMmapSegment.DefaultMaxFrameLength) + { + return new QwpSegmentRing(null, segmentCapacity, maxFrameLength); + } + + public void SetManagerWakeup(Action wakeup) + { + Volatile.Write(ref _managerWakeup, wakeup); + } + + /// + /// Engine subscribes here to get woken when the manager installs a hot spare; pairs with + /// producer's TryAppend returning false on no-spare. + /// + public void SetSpareInstalledCallback(Action callback) + { + Volatile.Write(ref _spareInstalledCallback, callback); + } + + /// + /// Manager subscribes here to learn that a spare it installed could not be adopted by the + /// producer (file move failed). Used to reconcile committed-bytes accounting. + /// + public void SetSpareAdoptionFailedCallback(Action callback) + { + Volatile.Write(ref _spareAdoptionFailed, callback); + } + + /// + /// Appends a frame for the single producer thread. Must not run concurrently with + /// : a stale _active local could otherwise write into a segment + /// freed by disposal (native-memory corruption). The producer is single-threaded by + /// contract; a Debug-build guard in fails loudly on violation. + /// + public bool TryAppend(ReadOnlySpan frame) + { + if (frame.Length == 0) + { + throw new ArgumentException("empty frames are not permitted on the wire", nameof(frame)); + } + + if (frame.Length + QwpMmapSegment.EnvelopeHeaderSize > _segmentCapacity) + { + throw new ArgumentException( + $"frame ({frame.Length} bytes) exceeds segment capacity ({_segmentCapacity} bytes); raise sf_max_bytes", + nameof(frame)); + } + + Interlocked.Increment(ref _appendsInFlight); + try + { + EnsureNotClosed(); + + // Seal-before-rotate (independent of spare availability) so trim → cap-free → spare-install + // can make progress when the disk cap is tight. + var active = Volatile.Read(ref _active); + if (active is not null && !active.IsSealed && active.WritePosition + QwpMmapSegment.EnvelopeHeaderSize + frame.Length > active.Capacity) + { + SealAndAddCurrentToSealed(); + active = null; + } + + if (active is null) + { + if (!TryAllocateNewActive()) + { + return false; + } + + active = Volatile.Read(ref _active)!; + } + + if (!active.TryAppend(frame)) + { + throw new InvalidOperationException("freshly allocated segment cannot accommodate the frame"); + } + + BumpPublishedFsn(); + CheckHighWaterAndWakeManager(active); + return true; + } + finally + { + Interlocked.Decrement(ref _appendsInFlight); + } + } + + public int TryReadFrame(long fsn, Span destination) + { + IQwpSegment? seg; + lock (_lock) + { + if (_closed) return -1; + seg = FindSegmentLocked(fsn); + } + + if (seg is null) return -1; + + var envelopeIndex = fsn - seg.BaseFsn; + var offset = seg.OffsetOfEnvelope(envelopeIndex); + if (offset is null) return -1; + + // Corrupt envelope (CRC mismatch / bad frame_len) must read as -1, not throw into the pump. + try + { + return seg.TryReadFrame(offset.Value, destination, out _); + } + catch (ObjectDisposedException) + { + return -1; + } + catch (InvalidDataException) + { + return -1; + } + catch (ArgumentException) + { + return -1; + } + } + + public void Acknowledge(long fsn) + { + var current = Volatile.Read(ref _ackedFsn); + if (fsn > current) + { + Volatile.Write(ref _ackedFsn, fsn); + Volatile.Read(ref _managerWakeup)?.Invoke(); + } + } + + /// + /// Caller (manager) takes ownership of returned segments and is responsible for Dispose + + /// file unlink. Returns null when nothing is eligible (no list allocation on no-op). + /// + public List? DrainTrimmable() + { + var acked = Volatile.Read(ref _ackedFsn); + List? drained = null; + lock (_lock) + { + while (_sealedSegments.Count > 0) + { + var oldest = _sealedSegments[0]; + var lastFsn = oldest.BaseFsn + oldest.EnvelopeCount - 1; + if (lastFsn > acked) + { + break; + } + + drained ??= new List(_sealedSegments.Count); + drained.Add(oldest); + _sealedSegments.RemoveAt(0); + } + } + + return drained; + } + + /// + /// Returns false (caller cleans up) if the ring is closed or already has a spare. + /// + public bool InstallHotSpare(string sparePath) + { + ArgumentNullException.ThrowIfNull(sparePath); + + lock (_lock) + { + if (_closed) return false; + if (_hotSparePath is not null) return false; + _hotSparePath = sparePath; + } + + Volatile.Read(ref _spareInstalledCallback)?.Invoke(); + return true; + } + + public int SealedSegmentCount + { + get + { + lock (_lock) return _sealedSegments.Count; + } + } + + public IQwpSegment? FindSegmentContaining(long fsn) + { + lock (_lock) + { + return _closed ? null : FindSegmentLocked(fsn); + } + } + + public void FlushActive() + { + if (_closed) return; + try + { + Volatile.Read(ref _active)?.Flush(); + } + catch (Exception ex) + { + // An msync/IO failure here defeats SF's on-disk durability promise — surface it. + System.Diagnostics.Trace.TraceWarning( + "QWP SF: flushing the active segment failed: {0}", ex); + } + } + + /// + public void Dispose() + { + IQwpSegment? active; + List sealedSnapshot; + string? sparePath; + lock (_lock) + { + if (_closed) + { + return; + } + + // The single producer must not be inside TryAppend while we free segments here. + Debug.Assert(Volatile.Read(ref _appendsInFlight) == 0, + "QwpSegmentRing.Dispose ran concurrently with TryAppend: the producer is not single-threaded"); + + _closed = true; + active = Volatile.Read(ref _active); + Volatile.Write(ref _active, null); + sparePath = _hotSparePath; + _hotSparePath = null; + sealedSnapshot = new List(_sealedSegments); + _sealedSegments.Clear(); + } + + GC.SuppressFinalize(this); + + for (var i = 0; i < sealedSnapshot.Count; i++) + { + SfCleanup.Dispose(sealedSnapshot[i]); + } + + SfCleanup.Dispose(active); + + if (sparePath is not null) + { + try + { + if (File.Exists(sparePath)) File.Delete(sparePath); + } + catch (Exception) + { + // next sender startup cleans stray .tmp via CleanupStaleSpares + } + } + } + + // Backstop for a memory-backed ring dropped without Dispose (the default ws:: transport is + // RAM-backed). Its QwpMemorySegments hold raw NativeMemory and carry no finalizer of their own, + // so the ring — their holder — frees them here. File-backed rings are skipped: their mmap + // segments self-release via ~QwpMmapSegment, and flushing mmap views from a finalizer is unsafe. + // Only this ring's own segment list + segments are touched; both are reachable from here and, + // being finalizer-free, are guaranteed not yet finalized. An unreachable ring cannot be racing a + // producer or the manager (both need a reference to it), so no lock is taken. + ~QwpSegmentRing() + { + if (_closed || _directory is not null) + { + return; + } + + SfCleanup.Dispose(_active); + for (var i = 0; i < _sealedSegments.Count; i++) + { + SfCleanup.Dispose(_sealedSegments[i]); + } + } + + private void BumpPublishedFsn() + { + // Atomic increment doubles as a release barrier: mmap bytes are visible before the FSN. + Interlocked.Increment(ref _publishedFsn); + } + + private void CheckHighWaterAndWakeManager(IQwpSegment active) + { + if (_wakeRequestedForActive) return; + if (Volatile.Read(ref _hotSparePath) is not null) return; + if (active.WritePosition < _highWaterTrigger) return; + _wakeRequestedForActive = true; + Volatile.Read(ref _managerWakeup)?.Invoke(); + } + + private bool TryAllocateNewActive() + { + var baseFsn = Volatile.Read(ref _publishedFsn) + 1; + + if (IsMemoryBacked) + { + return TryAllocateNewActiveMemory(baseFsn); + } + + var generation = Interlocked.Increment(ref _fileGeneration); + var realPath = Path.Combine(_directory!, BuildFileName(generation)); + + var sparePath = Interlocked.Exchange(ref _hotSparePath, null); + if (sparePath is not null && TryAdoptSpare(sparePath, realPath, baseFsn)) + { + _wakeRequestedForActive = false; + Volatile.Read(ref _managerWakeup)?.Invoke(); + return true; + } + + var wakeup = Volatile.Read(ref _managerWakeup); + if (wakeup is not null) + { + // Adoption failed (rare File.Move error) → spare bytes are gone but the manager's + // committed accounting still includes them. Signal so it reconciles next tick. + if (sparePath is not null) + { + Volatile.Read(ref _spareAdoptionFailed)?.Invoke(); + } + wakeup(); + return false; + } + + // Standalone mode (no manager) — ring-only unit tests. + QwpMmapSegment? seg = null; + try + { + seg = QwpMmapSegment.Open(realPath, _segmentCapacity, baseFsn, _maxFrameLength); + if (!PublishActive(seg)) + { + SfCleanup.Dispose(seg); + return false; + } + _wakeRequestedForActive = false; + return true; + } + catch (Exception) + { + if (seg is not null) SfCleanup.Dispose(seg); + return false; + } + } + + private bool TryAllocateNewActiveMemory(long baseFsn) + { + long currentTotal; + lock (_lock) + { + currentTotal = 0; + for (var i = 0; i < _sealedSegments.Count; i++) + { + currentTotal += _sealedSegments[i].Capacity; + } + // _active was nulled by SealAndAddCurrentToSealed before we got here. + } + + var maxTotal = Volatile.Read(ref _maxTotalBytes); + if (currentTotal + _segmentCapacity > maxTotal) + { + // Wake the manager so it trims acked segments; producer waits in AppendBlockingSlow. + Volatile.Read(ref _managerWakeup)?.Invoke(); + return false; + } + + QwpMemorySegment? seg = null; + try + { + seg = QwpMemorySegment.Allocate(_segmentCapacity, baseFsn, _maxFrameLength); + if (!PublishActive(seg)) + { + SfCleanup.Dispose(seg); + return false; + } + _wakeRequestedForActive = false; + return true; + } + catch + { + if (seg is not null) SfCleanup.Dispose(seg); + return false; + } + } + + private void SealAndAddCurrentToSealed() + { + var active = Volatile.Read(ref _active); + if (active is null) return; + if (!active.IsSealed) active.Seal(); + lock (_lock) + { + if (!_closed) + { + _sealedSegments.Add(active); + } + Volatile.Write(ref _active, null); + } + } + + private bool TryAdoptSpare(string sparePath, string realPath, long baseFsn) + { + QwpMmapSegment? seg = null; + try + { + if (!File.Exists(sparePath)) return false; + File.Move(sparePath, realPath); + seg = QwpMmapSegment.Open(realPath, _segmentCapacity, baseFsn, _maxFrameLength); + if (!PublishActive(seg)) + { + SfCleanup.Dispose(seg); + return false; + } + return true; + } + catch (Exception) + { + if (seg is not null) SfCleanup.Dispose(seg); + SfCleanup.DeleteFile(sparePath); + return false; + } + } + + private bool PublishActive(IQwpSegment seg) + { + lock (_lock) + { + if (_closed) + { + return false; + } + + Volatile.Write(ref _active, seg); + return true; + } + } + + private IQwpSegment? FindSegmentLocked(long fsn) + { + for (var i = 0; i < _sealedSegments.Count; i++) + { + var s = _sealedSegments[i]; + if (fsn >= s.BaseFsn && fsn < s.NextFsn) return s; + } + + var active = Volatile.Read(ref _active); + if (active is not null && fsn >= active.BaseFsn && fsn < active.NextFsn) + { + return active; + } + + return null; + } + + private void EnsureNotClosed() + { + // Volatile read keeps the producer hot path lock-free; the write in Dispose runs under _lock. + if (_closed) throw new ObjectDisposedException(nameof(QwpSegmentRing)); + } + + internal static string BuildFileName(long generation) + { + return FilenamePrefix + generation.ToString("x16", CultureInfo.InvariantCulture) + FilenameSuffix; + } + + private static long ScanMaxGeneration(string directory) + { + var max = -1L; + foreach (var path in QwpFiles.EnumerateFiles(directory, FilenamePrefix + "*" + FilenameSuffix)) + { + var name = Path.GetFileName(path); + if (name.StartsWith(SparePrefix, StringComparison.Ordinal)) continue; + var hex = name.AsSpan(FilenamePrefix.Length, + name.Length - FilenamePrefix.Length - FilenameSuffix.Length); + if (hex.Length != 16) continue; + if (!long.TryParse(hex, System.Globalization.NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var gen)) continue; + if (gen > max) max = gen; + } + return max; + } + + private static void CleanupStaleSpares(string directory) + { + try + { + foreach (var path in QwpFiles.EnumerateFiles(directory, SparePrefix + "*" + SpareSuffix)) + { + SfCleanup.DeleteFile(path); + } + } + catch (Exception) { /* best-effort */ } + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpSenderErrorDispatcher.cs b/src/net-questdb-client/Qwp/Sf/QwpSenderErrorDispatcher.cs new file mode 100644 index 00000000..9cf0013b --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSenderErrorDispatcher.cs @@ -0,0 +1,141 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Diagnostics; +using System.Threading.Channels; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +internal sealed class QwpSenderErrorDispatcher : IDisposable +{ + private readonly Channel _inbox; + private readonly object _offerLock = new(); + private readonly object _lifecycleLock = new(); + private readonly SenderErrorHandler _handler; + private readonly bool _hasCustomHandler; + private readonly CancellationTokenSource _shutdown = new(); + private readonly int _capacity; + private long _dropped; + private long _delivered; + private Task? _loop; + private int _started; + private int _disposed; + + public QwpSenderErrorDispatcher(SenderErrorHandler? handler, int capacity) + { + if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity)); + _capacity = capacity; + _hasCustomHandler = handler != null; + _handler = handler ?? DefaultHandler; + // Unbounded — capacity is enforced manually in Offer; both Offer and the loop read. + _inbox = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false, + }); + } + + public long DroppedNotifications => Volatile.Read(ref _dropped); + public long TotalDelivered => Volatile.Read(ref _delivered); + + public bool HasDeliveredToCustomHandler => + _hasCustomHandler && Volatile.Read(ref _delivered) > 0; + + public bool Offer(SenderError error) + { + if (Volatile.Read(ref _disposed) != 0) return false; + + // Manual DropOldest: evict-then-write under the lock keeps _dropped exact. + lock (_offerLock) + { + while (_inbox.Reader.Count >= _capacity && _inbox.Reader.TryRead(out _)) + { + Interlocked.Increment(ref _dropped); + } + _inbox.Writer.TryWrite(error); + } + + if (Interlocked.CompareExchange(ref _started, 1, 0) == 0) + { + // _lifecycleLock pairs with Dispose's _loop read: Dispose joins this loop, or this + // branch sees the disposal first and never starts on an already-disposed CTS. + lock (_lifecycleLock) + { + if (Volatile.Read(ref _disposed) == 0) + { + _loop = Task.Run(DispatchLoopAsync); + } + } + } + return true; + } + + private async Task DispatchLoopAsync() + { + try + { + while (await _inbox.Reader.WaitToReadAsync(_shutdown.Token).ConfigureAwait(false)) + { + while (_inbox.Reader.TryRead(out var err)) + { + Interlocked.Increment(ref _delivered); + try { _handler(err); } + catch (Exception t) { Trace.TraceError($"SenderErrorHandler threw: {t}"); } + } + } + } + catch (OperationCanceledException) { } + // A racing Dispose can dispose _shutdown while WaitToReadAsync observes its token; treat + // the resulting ObjectDisposedException as a clean shutdown rather than a faulted task. + catch (ObjectDisposedException) { } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _inbox.Writer.TryComplete(); + try { _shutdown.Cancel(); } catch { } + Task? loop; + lock (_lifecycleLock) + { + loop = _loop; + } + try { loop?.Wait(TimeSpan.FromMilliseconds(200)); } catch { } + _shutdown.Dispose(); + } + + public static readonly SenderErrorHandler DefaultHandler = static err => + { + if (err.AppliedPolicy == SenderErrorPolicy.Halt) + { + Trace.TraceError($"QuestDB sender HALT: {err}"); + } + else + { + Trace.TraceWarning($"QuestDB sender DROP: {err}"); + } + }; +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpSlotLock.cs b/src/net-questdb-client/Qwp/Sf/QwpSlotLock.cs new file mode 100644 index 00000000..28bcfa39 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSlotLock.cs @@ -0,0 +1,197 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Text; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// Advisory exclusive lock on a store-and-forward slot directory. +/// +/// +/// Each slot directory (<sf_dir>/<sender_id>/) is owned by a single live +/// sender at a time. The lock is implemented as a opened on +/// <slot>/.lock with , held for the lifetime of the +/// instance and released on or +/// kernel-on-process-exit. +/// +/// Local filesystems only — NFS and SMB do not honour FileShare.None reliably across +/// hosts. SF is documented as local-FS only. +/// +internal sealed class QwpSlotLock : IDisposable +{ + private const string LockFileName = ".lock"; + private const string PidSidecarName = ".lock.pid"; + private const string HeartbeatFileName = ".heartbeat"; + + private readonly FileStream _file; + private readonly string _heartbeatPath; + private readonly object _stateLock = new(); + private bool _disposed; + + private QwpSlotLock(string slotDirectory, string lockFilePath, FileStream file) + { + SlotDirectory = slotDirectory; + LockFilePath = lockFilePath; + _heartbeatPath = Path.Combine(slotDirectory, HeartbeatFileName); + _file = file; + RefreshHeartbeat(); + } + + /// Updates the slot's heartbeat file mtime. Best-effort. + public void RefreshHeartbeat() + { + try + { + if (!File.Exists(_heartbeatPath)) + { + using var fs = new FileStream(_heartbeatPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + } + // SetLength(0) is a no-op on an already-empty file on some filesystems. + File.SetLastWriteTimeUtc(_heartbeatPath, DateTime.UtcNow); + } + catch + { + } + } + + /// The slot directory we hold the lock for. + public string SlotDirectory { get; } + + /// Full path of the lock file. + public string LockFilePath { get; } + + /// + /// Runs against while holding an + /// internal mutex that excludes . Returns false (without + /// invoking the action) if the lock has already been disposed. + /// + public bool TryRunUnderLock(Action action) + { + ArgumentNullException.ThrowIfNull(action); + lock (_stateLock) + { + if (_disposed) return false; + action(SlotDirectory); + return true; + } + } + + /// + /// Acquires the lock on the given slot. Creates the slot directory if it doesn't exist. + /// + /// If another process or thread is already holding the lock, + /// or the slot directory is on a networked filesystem. + public static QwpSlotLock Acquire(string slotDirectory) + { + ArgumentNullException.ThrowIfNull(slotDirectory); + RejectIfNetworkPath(slotDirectory); + QwpFiles.EnsureDirectory(slotDirectory); + + var path = Path.Combine(slotDirectory, LockFileName); + var pidPath = Path.Combine(slotDirectory, PidSidecarName); + var fs = QwpFiles.TryOpenExclusive(path); + if (fs is null) + { + throw new IngressError( + ErrorCode.ConfigError, + $"slot {slotDirectory} is already locked{ReadHolderHint(pidPath)} (lock file: {path})"); + } + + WritePidSidecar(pidPath); + return new QwpSlotLock(slotDirectory, path, fs); + } + + /// Like but returns null on collision instead of throwing. + public static QwpSlotLock? TryAcquire(string slotDirectory) + { + ArgumentNullException.ThrowIfNull(slotDirectory); + RejectIfNetworkPath(slotDirectory); + QwpFiles.EnsureDirectory(slotDirectory); + + var path = Path.Combine(slotDirectory, LockFileName); + var pidPath = Path.Combine(slotDirectory, PidSidecarName); + var fs = QwpFiles.TryOpenExclusive(path); + if (fs is null) return null; + + WritePidSidecar(pidPath); + return new QwpSlotLock(slotDirectory, path, fs); + } + + private static void RejectIfNetworkPath(string slotDirectory) + { + if (QwpFiles.LooksLikeNetworkPath(slotDirectory)) + { + throw new IngressError( + ErrorCode.ConfigError, + $"sf_dir `{slotDirectory}` looks like a networked filesystem (UNC / NFS-style mount); " + + "FileShare.None advisory locking is unreliable across hosts on NFS/SMB. " + + "Use a local-FS path for sf_dir."); + } + } + + private static void WritePidSidecar(string pidPath) + { + try + { + File.WriteAllText(pidPath, Environment.ProcessId + "\n", Encoding.UTF8); + } + catch + { + } + } + + private static string ReadHolderHint(string pidPath) + { + try + { + if (!File.Exists(pidPath)) return string.Empty; + var s = File.ReadAllText(pidPath, Encoding.ASCII).Trim(); + return s.Length == 0 ? string.Empty : $" by pid {s}"; + } + catch + { + return string.Empty; + } + } + + /// + public void Dispose() + { + lock (_stateLock) + { + if (_disposed) return; + _disposed = true; + try + { + _file.Dispose(); + } + catch (Exception) + { + } + } + } +} diff --git a/src/net-questdb-client/Qwp/Sf/QwpTrackedCursorTransport.cs b/src/net-questdb-client/Qwp/Sf/QwpTrackedCursorTransport.cs new file mode 100644 index 00000000..90793338 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpTrackedCursorTransport.cs @@ -0,0 +1,164 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp.Sf; + +/// +/// Decorator that records the outcome of against a +/// entry so the SF cursor engine's reconnect +/// loop can rotate through configured addresses without owning the tracker +/// directly. Auth failures are *not* classified — the engine treats them as +/// terminal regardless of host. +/// +internal sealed class QwpTrackedCursorTransport : IQwpCursorTransport +{ + private readonly TimeSpan _connectTimeout; + private readonly int _hostIndex; + private readonly IQwpCursorTransport _inner; + private readonly QwpHostHealthTracker _tracker; + + public QwpTrackedCursorTransport(IQwpCursorTransport inner, QwpHostHealthTracker tracker, int hostIndex) + : this(inner, tracker, hostIndex, Timeout.InfiniteTimeSpan) + { + } + + public QwpTrackedCursorTransport(IQwpCursorTransport inner, QwpHostHealthTracker tracker, int hostIndex, + TimeSpan connectTimeout) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _hostIndex = hostIndex; + _connectTimeout = connectTimeout; + } + + public (string Host, int Port)? Endpoint => _inner.Endpoint; + + public int NegotiatedMaxBatchSize => _inner.NegotiatedMaxBatchSize; + + public string? NegotiatedZone => _inner.NegotiatedZone; + + public async Task ConnectAsync(CancellationToken cancellationToken) + { + CancellationTokenSource? timeoutCts = null; + CancellationToken effectiveCt = cancellationToken; + if (_connectTimeout != Timeout.InfiniteTimeSpan && _connectTimeout > TimeSpan.Zero) + { + timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_connectTimeout); + effectiveCt = timeoutCts.Token; + } + + try + { + await _inner.ConnectAsync(effectiveCt).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts is not null && timeoutCts.IsCancellationRequested + && !cancellationToken.IsCancellationRequested) + { + _tracker.RecordTransportError(_hostIndex); + throw new IngressError(ErrorCode.SocketError, + $"WebSocket upgrade exceeded auth_timeout={_connectTimeout.TotalMilliseconds}ms"); + } + catch (QwpIngressRoleRejectedException ex) + { + // Record the zone before the reject so a host that role-rejects but exposes a zone + // still contributes to the same-zone vs other-zone tiering for later walks. + if (!string.IsNullOrEmpty(ex.Zone)) + { + _tracker.RecordZone(_hostIndex, ex.Zone); + } + _tracker.RecordRoleReject(_hostIndex, ex.IsTransient); + throw; + } + catch (IngressError ex) when (ex.code is ErrorCode.AuthError or ErrorCode.DurableAckNotSupported) + { + throw; + } + catch + { + _tracker.RecordTransportError(_hostIndex); + throw; + } + finally + { + timeoutCts?.Dispose(); + } + + // Record the zone on the success path too (not only on role-reject) so same-zone tiering + // engages among healthy hosts; RecordZone is a no-op when the server advertised no zone. + _tracker.RecordZone(_hostIndex, _inner.NegotiatedZone); + _tracker.RecordSuccess(_hostIndex); + } + + public async Task SendBinaryAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + try + { + await _inner.SendBinaryAsync(data, cancellationToken).ConfigureAwait(false); + } + catch + { + _tracker.RecordMidStreamFailure(_hostIndex); + throw; + } + } + + public async Task ReceiveFrameAsync(Memory destination, CancellationToken cancellationToken) + { + try + { + return await _inner.ReceiveFrameAsync(destination, cancellationToken).ConfigureAwait(false); + } + catch + { + _tracker.RecordMidStreamFailure(_hostIndex); + throw; + } + } + + public async Task<(int Read, byte[] Buffer)> ReceiveFrameAsync( + byte[] initialBuffer, int maxBytes, CancellationToken cancellationToken) + { + try + { + return await _inner.ReceiveFrameAsync(initialBuffer, maxBytes, cancellationToken).ConfigureAwait(false); + } + catch + { + _tracker.RecordMidStreamFailure(_hostIndex); + throw; + } + } + + public Task CloseAsync(CancellationToken cancellationToken) + => _inner.CloseAsync(cancellationToken); + + public void Dispose() => _inner.Dispose(); +} diff --git a/src/net-questdb-client/Qwp/Sf/SfCleanup.cs b/src/net-questdb-client/Qwp/Sf/SfCleanup.cs new file mode 100644 index 00000000..d0070925 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/SfCleanup.cs @@ -0,0 +1,106 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Sf; + +/// +/// Best-effort cleanup helpers used by SF dispose and trim paths. Swallows the limited set of +/// exceptions we expect during cleanup (I/O, double-dispose, semaphore-full, cancellation), +/// and lets unexpected exceptions (NRE, ArgumentException, etc.) escape so real bugs surface. +/// +internal static class SfCleanup +{ + /// Dispose without throwing on the documented "expected" exceptions. + public static void Dispose(IDisposable? d) + { + if (d is null) return; + try + { + d.Dispose(); + } + catch (Exception ex) when (IsExpectedCleanupError(ex)) + { + // expected during cleanup — disposal must not throw further + } + } + + /// Delete if it exists; swallow expected I/O errors. + public static void DeleteFile(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) when (IsExpectedCleanupError(ex)) + { + // file may persist across restart; recovery sweeps clean stragglers + } + } + + /// Quarantine a corrupt SF segment so the next open skips it without losing the bytes. + public static void RenameFileToCorrupt(string path) + { + try + { + if (!File.Exists(path)) return; + var dest = path + ".corrupt"; + if (File.Exists(dest)) File.Delete(dest); + File.Move(path, dest); + } + catch (Exception ex) when (IsExpectedCleanupError(ex)) + { + } + } + + /// Run swallowing only expected cleanup exceptions. + public static void Run(Action action) + { + try + { + action(); + } + catch (Exception ex) when (IsExpectedCleanupError(ex)) + { + // expected during cleanup + } + } + + private static bool IsExpectedCleanupError(Exception ex) + { + if (ex is AggregateException agg) + { + foreach (var inner in agg.Flatten().InnerExceptions) + { + if (!IsExpectedCleanupError(inner)) return false; + } + return true; + } + + return ex is IOException + || ex is UnauthorizedAccessException + || ex is ObjectDisposedException + || ex is SemaphoreFullException + || ex is OperationCanceledException; + } +} diff --git a/src/net-questdb-client/Sender.cs b/src/net-questdb-client/Sender.cs index 9ae01485..4331a7e3 100644 --- a/src/net-questdb-client/Sender.cs +++ b/src/net-questdb-client/Sender.cs @@ -68,20 +68,23 @@ public static ISender New(SenderOptions? options = null) { if (options is null) { - return new HttpSender("http::addr=localhost:9000;"); + return new HttpSender(new SenderOptions("http::addr=localhost:9000;")); } + options.EnsureValid(); - switch (options.protocol) + return options.protocol switch { - case ProtocolType.http: - case ProtocolType.https: - return new HttpSender(options); - case ProtocolType.tcp: - case ProtocolType.tcps: - return new TcpSender(options); - } - - throw new NotImplementedException(); + ProtocolType.http or ProtocolType.https => new HttpSender(options), + ProtocolType.tcp or ProtocolType.tcps => new TcpSender(options), +#if NET7_0_OR_GREATER + ProtocolType.ws or ProtocolType.wss => new QwpWebSocketSender(options), +#else + ProtocolType.ws or ProtocolType.wss => throw new IngressError(ErrorCode.ConfigError, + "ws::/wss:: senders require .NET 7 or later; HTTP and TCP transports remain available on net6.0"), +#endif + _ => throw new ArgumentOutOfRangeException(nameof(options.protocol), + options.protocol, "unknown ProtocolType"), + }; } /// @@ -93,4 +96,51 @@ public static SenderOptions Configure(string confStr) { return new SenderOptions(confStr); } + + /// + /// Creates an from the connect-string stored in the + /// QDB_CLIENT_CONF environment variable. Convenience for cloud and 12-factor + /// deployments where configuration lives in env, not in code. + /// + /// Thrown when QDB_CLIENT_CONF is unset or blank. + public static ISender FromEnv() + { + var confStr = Environment.GetEnvironmentVariable(EnvConfStr); + if (string.IsNullOrWhiteSpace(confStr)) + { + throw new IngressError(ErrorCode.ConfigError, + $"{EnvConfStr} environment variable is not set"); + } + return New(confStr); + } + + internal const string EnvConfStr = "QDB_CLIENT_CONF"; + +#if NET7_0_OR_GREATER + /// + /// Builds a ws::/wss:: sender and returns the QWP-specific interface so callers can use + /// , + /// , and + /// without an + /// (IQwpWebSocketSender) cast. Mirrors . + /// + public static IQwpWebSocketSender NewQwp(string confStr) + { + return NewQwp(Configure(confStr)); + } + + /// + public static IQwpWebSocketSender NewQwp(SenderOptions options) + { + ArgumentNullException.ThrowIfNull(options); + // Validate the scheme up front rather than constructing a live HTTP/TCP sender (which opens + // sockets / handlers) only to dispose it on the type mismatch. + if (options.protocol is not (ProtocolType.ws or ProtocolType.wss)) + { + throw new IngressError(ErrorCode.ConfigError, + "NewQwp requires a ws:: or wss:: connect string"); + } + return (IQwpWebSocketSender)New(options); + } +#endif } \ No newline at end of file diff --git a/src/net-questdb-client/Senders/AbstractSender.cs b/src/net-questdb-client/Senders/AbstractSender.cs index bbdb8c67..b50b646f 100644 --- a/src/net-questdb-client/Senders/AbstractSender.cs +++ b/src/net-questdb-client/Senders/AbstractSender.cs @@ -22,9 +22,11 @@ * ******************************************************************************/ +using System.Numerics; using System.Runtime.CompilerServices; using QuestDB.Buffers; using QuestDB.Enums; +using QuestDB.Qwp; using QuestDB.Utils; namespace QuestDB.Senders; @@ -279,6 +281,105 @@ public ISender Column(ReadOnlySpan name, decimal value) return this; } + // QWP carries an explicit per-column decimal width + scale; ILP has a single variable-length + // binary decimal. These overloads honour the same (value, scale) contract over ILP by rounding to + // `scale` (half away from zero, as the QWP path and mainstream SQL do) and emitting the V3 binary + // decimal. The width name (64/128/256) only bounds the accepted scale here — the ILP wire form is + // width-agnostic. The raw-limb overloads route through System.Decimal and therefore reject values + // whose magnitude exceeds its 96-bit mantissa or whose scale exceeds 28; use ws/wss for the full + // DECIMAL128 / DECIMAL256 range. + + /// + public ISender ColumnDecimal64(ReadOnlySpan name, decimal value, byte scale) + => AppendIlpDecimalScaled(name, value, scale, QwpConstants.MaxDecimal64Scale, "Decimal64"); + + /// + public ISender ColumnDecimal128(ReadOnlySpan name, decimal value, byte scale) + => AppendIlpDecimalScaled(name, value, scale, QwpConstants.MaxDecimal128Scale, "Decimal128"); + + /// + public ISender ColumnDecimal256(ReadOnlySpan name, decimal value, byte scale) + => AppendIlpDecimalScaled(name, value, scale, QwpConstants.MaxDecimal256Scale, "Decimal256"); + + /// + public ISender ColumnDecimal64(ReadOnlySpan name, long unscaledValue, byte scale) + { + ValidateDecimalScale(scale, QwpConstants.MaxDecimal64Scale, "Decimal64"); + return AppendIlpDecimalFromMantissa(name, new BigInteger(unscaledValue), scale); + } + + /// + public ISender ColumnDecimal128(ReadOnlySpan name, long lo, long hi, byte scale) + { + ValidateDecimalScale(scale, QwpConstants.MaxDecimal128Scale, "Decimal128"); + var mantissa = (new BigInteger(hi) << 64) + new BigInteger((ulong)lo); + return AppendIlpDecimalFromMantissa(name, mantissa, scale); + } + + /// + public ISender ColumnDecimal256(ReadOnlySpan name, long l0, long l1, long l2, long l3, byte scale) + { + ValidateDecimalScale(scale, QwpConstants.MaxDecimal256Scale, "Decimal256"); + var mantissa = (new BigInteger(l3) << 192) + + (new BigInteger((ulong)l2) << 128) + + (new BigInteger((ulong)l1) << 64) + + new BigInteger((ulong)l0); + return AppendIlpDecimalFromMantissa(name, mantissa, scale); + } + + private ISender AppendIlpDecimalScaled(ReadOnlySpan name, decimal value, byte scale, int maxScale, + string typeName) + { + ValidateDecimalScale(scale, maxScale, typeName); + // Round to the requested scale, half away from zero, matching the QWP path. Math.Round caps the + // digit count at 28; a System.Decimal can't carry more than 28 fractional digits, so a target + // scale above 28 needs no rounding and the value is written as-is — the server rescales to the + // column's declared scale. + var rounded = Math.Round(value, Math.Min((int)scale, MaxDecimalScale), MidpointRounding.AwayFromZero); + Buffer.Column(name, rounded); + return this; + } + + // Routes a raw two's-complement unscaled mantissa + scale through the ILP binary decimal encoder by + // reconstructing a System.Decimal. Rejects values ILP cannot represent: a scale beyond + // System.Decimal's 28-digit limit, or a magnitude beyond its 96-bit mantissa. Both are representable + // over ws/wss, so the error points the caller there. + private ISender AppendIlpDecimalFromMantissa(ReadOnlySpan name, BigInteger mantissa, byte scale) + { + if (scale > MaxDecimalScale) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"decimal scale {scale} exceeds the {MaxDecimalScale} digits representable over ILP; use a ws/wss sender"); + } + + var negative = mantissa.Sign < 0; + var magnitude = BigInteger.Abs(mantissa); + if (magnitude > Max96BitMagnitude) + { + throw new IngressError(ErrorCode.InvalidApiCall, + "decimal magnitude exceeds the 96-bit range representable over ILP; use a ws/wss sender"); + } + + var lo = (uint)(magnitude & uint.MaxValue); + var mid = (uint)((magnitude >> 32) & uint.MaxValue); + var hi = (uint)((magnitude >> 64) & uint.MaxValue); + Buffer.Column(name, new decimal((int)lo, (int)mid, (int)hi, negative, scale)); + return this; + } + + private static void ValidateDecimalScale(byte scale, int maxScale, string typeName) + { + if (scale > maxScale) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"{typeName} scale {scale} exceeds maximum {maxScale}"); + } + } + + // Largest scale System.Decimal can carry, and its largest unsigned 96-bit mantissa (2^96 - 1). + private const int MaxDecimalScale = 28; + private static readonly BigInteger Max96BitMagnitude = (BigInteger.One << 96) - BigInteger.One; + /// public ISender Column(ReadOnlySpan name, Guid value) { diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs index bceb6cf1..54636820 100644 --- a/src/net-questdb-client/Senders/HttpSender.cs +++ b/src/net-questdb-client/Senders/HttpSender.cs @@ -59,6 +59,8 @@ internal class HttpSender : AbstractSender private readonly Func _sendRequestFactory; private readonly Func _settingRequestFactory; + private Lazy? _trustRoot; + /// /// Manages round-robin address rotation for failover. /// @@ -93,24 +95,6 @@ public HttpSender(string confStr) : this(new SenderOptions(confStr)) { } - /// - /// Configure and initialize the SocketsHttpHandler and HttpClient, set TLS and authentication options, determine the - /// Line Protocol version (probing /settings when set to Auto), and create the internal send buffer. - /// - /// - /// - Applies pool and connection settings from Options. - /// - When using HTTPS, configures TLS protocols, optional remote-certificate validation override (when tls_verify is - /// unsafe_off), optional custom root CA installation, and optional client certificates. - /// - Sets connection timeout, PreAuthenticate, BaseAddress, and disables HttpClient timeout. - /// - Adds Basic or Bearer Authorization header when credentials or token are provided. - /// - If protocol_version is Auto, probes the server's /settings with a 1-second retry window to select the highest - /// mutually supported protocol up to V3, falling back to V1 on errors or unexpected responses. - /// - Initializes the Buffer with init_buf_size, max_name_len, max_buf_size, and the chosen protocol version. - /// - /// - /// Creates a configured for a specific host. - /// Each handler is isolated to prevent TLS TargetHost conflicts between different addresses. - /// private SocketsHttpHandler CreateHandler(string host) { var handler = new SocketsHttpHandler @@ -124,12 +108,19 @@ private SocketsHttpHandler CreateHandler(string host) handler.SslOptions.TargetHost = host; handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13; + if (_trustRoot is null && !string.IsNullOrEmpty(Options.tls_roots)) + { + _trustRoot = new Lazy( + () => QwpTlsAuth.LoadTrustRoot(Options.tls_roots!, Options.tls_roots_password)); + } + if (Options.tls_verify == TlsVerifyType.unsafe_off) { handler.SslOptions.RemoteCertificateValidationCallback += (_, _, _, _) => true; } else { + var trustRoot = _trustRoot; handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) => { @@ -138,24 +129,20 @@ private SocketsHttpHandler CreateHandler(string host) return false; } - if (Options.tls_roots != null) + if (trustRoot is not null) { chain!.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - chain.ChainPolicy.CustomTrustStore.Add( - X509Certificate2.CreateFromPemFile(Options.tls_roots, Options.tls_roots_password)); + if (chain.ChainPolicy.CustomTrustStore.Count == 0) + { + chain.ChainPolicy.CustomTrustStore.Add(trustRoot.Value); + } } - return chain!.Build(new X509Certificate2(certificate!)); + using var serverCert = new X509Certificate2(certificate!); + return chain!.Build(serverCert); }; } - if (!string.IsNullOrEmpty(Options.tls_roots)) - { - handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection(); - handler.SslOptions.ClientCertificates.Add( - X509Certificate2.CreateFromPemFile(Options.tls_roots!, Options.tls_roots_password)); - } - if (Options.client_cert is not null) { handler.SslOptions.ClientCertificates ??= new X509Certificate2Collection(); @@ -207,7 +194,8 @@ private void Build() Options.init_buf_size, Options.max_name_len, Options.max_buf_size, - protocolVersion + protocolVersion, + Options.convert_local_to_utc ); } @@ -838,6 +826,13 @@ public override void Dispose() } _handlerCache.Clear(); + + if (_trustRoot is { IsValueCreated: true }) + { + _trustRoot.Value.Dispose(); + } + _trustRoot = null; + Buffer.Clear(); Buffer.TrimExcessBuffers(); } diff --git a/src/net-questdb-client/Senders/IQwpQueryClient.cs b/src/net-questdb-client/Senders/IQwpQueryClient.cs new file mode 100644 index 00000000..0197f079 --- /dev/null +++ b/src/net-questdb-client/Senders/IQwpQueryClient.cs @@ -0,0 +1,95 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Qwp.Query; + +namespace QuestDB.Senders; + +/// +/// Public surface of the QWP egress query client. One instance owns one WebSocket; one +/// in-flight query at a time per the Phase-1 server contract. +/// +/// +/// may block the calling thread up to 5 seconds while the +/// in-flight Execute drains; prefer from async +/// contexts (UI threads on WinForms / WPF / Avalonia in particular). +/// +/// Authentication errors are terminal: a 401/403 from any failover candidate aborts +/// the loop without trying remaining hosts. Re-running an unsupported credential against +/// every host wastes time and floods server logs, so the loop fails fast instead. +/// +/// Cancellation via is terminal: the receive loop +/// cannot be safely interrupted mid-frame, so a token cancellation tears the connection down +/// and the client transitions to a non-recoverable state. Use for +/// cooperative cancellation that keeps the connection alive. +/// +public interface IQwpQueryClient : IDisposable, IAsyncDisposable +{ + /// Server identity / role observed during connect; null for v1 servers. + QwpServerInfo? ServerInfo { get; } + + /// QWP version negotiated at the WebSocket upgrade; 0 until connect completes. + int NegotiatedVersion { get; } + + /// Server-selected batch-body compression (e.g. "zstd;level=3"); null means raw. + string? NegotiatedCompression { get; } + + /// + /// true when the most recent Dispose/DisposeAsync hit its 5-second + /// grace window before the in-flight Execute drained — the native zstd handle was released + /// best-effort but the I/O loop may still be running. + /// + bool WasLastCloseTimedOut { get; } + + /// + /// Submits the SQL query and synchronously drives the handler until the server emits a + /// terminator (RESULT_END, EXEC_DONE, or QUERY_ERROR). Throws on transport or protocol + /// failure; query-level errors surface via . + /// + void Execute(string sql, QwpColumnBatchHandler handler); + + /// + void Execute(string sql, QwpBindSetter binds, QwpColumnBatchHandler handler); + + /// + Task ExecuteAsync(string sql, QwpColumnBatchHandler handler, CancellationToken cancellationToken = default); + + /// + Task ExecuteAsync(string sql, QwpBindSetter binds, QwpColumnBatchHandler handler, + CancellationToken cancellationToken = default); + + /// + /// Posts a CANCEL frame for the in-flight query. Thread-safe. The query terminates + /// with a QUERY_ERROR (status STATUS_CANCELLED) or, if the server raced to + /// finish, a normal RESULT_END. No-op if no query is in flight. + /// + /// + /// Cooperative cancel only: this method does not interrupt an in-progress + /// . + /// If the server hangs and never acknowledges, ExecuteAsync will not return. For a + /// hard cancel that aborts the receive loop, pass a to + /// ExecuteAsync and cancel that token instead. + /// + void Cancel(); +} diff --git a/src/net-questdb-client/Senders/IQwpWebSocketSender.cs b/src/net-questdb-client/Senders/IQwpWebSocketSender.cs new file mode 100644 index 00000000..dbc6884b --- /dev/null +++ b/src/net-questdb-client/Senders/IQwpWebSocketSender.cs @@ -0,0 +1,160 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Senders; + +/// +/// Extends with WebSocket-only operations. +/// +/// +/// returns an for every transport. Users that +/// opted into ws:: or wss:: can cast to this interface to access ping and per-table +/// seqTxn watermarks. Durable watermarks require request_durable_ack=on on the +/// connect string; the committed watermark is always populated once the server has ACKed any +/// batch on a connection. +/// +/// Authentication errors are terminal on both the initial connect and SF reconnect +/// loops: a 401/403 against any failover candidate aborts the loop instead of cycling through +/// the remaining hosts. Retrying a rejected credential floods server logs and rarely recovers. +/// +public interface IQwpWebSocketSender : ISender +{ + /// + /// Highest committed seqTxn the server has acknowledged for the given table on this + /// connection. Returns -1 when the server has not yet sent a watermark for the table. + /// + /// + /// Per-table watermarks arrive only when request_durable_ack is enabled. Without + /// opt-in the value stays at -1. + /// + long GetHighestAckedSeqTxn(string tableName); + + /// + /// Highest seqTxn the server has reported as durably uploaded (object-store watermark). + /// Returns -1 when no durable watermark has been observed. + /// + long GetHighestDurableSeqTxn(string tableName); + + /// + /// Drains the in-flight ACK window. After it returns successfully every batch sent so far has + /// been acknowledged by the server and per-table seqTxn watermarks reflect that. Bounded by + /// ping_timeout; on an idle connection with nothing in flight it returns immediately + /// and is NOT a wire-level liveness probe (ClientWebSocket exposes no PING API). + /// + void Ping(CancellationToken ct = default); + + /// + ValueTask PingAsync(CancellationToken ct = default); + + /// + /// Highest cursor frame sequence number (FSN) the server has acknowledged on the current + /// connection. Returns -1 when nothing has been ack'd yet. Snapshot accessor; pair + /// with for a bounded wait. + /// + long AckedFsn { get; } + + /// + /// Same as but returns the FSN of the highest frame + /// published into the cursor engine by this call. Pair with + /// to confirm a specific publish has been acknowledged. Returns the current + /// -equivalent watermark when nothing was published. + /// + Task FlushAndGetSequenceAsync(CancellationToken ct = default); + + /// + /// Blocks until reaches or + /// elapses. Returns true if the watermark caught up, + /// false on timeout. A non-positive returns true + /// immediately. + /// + /// if the I/O loop has latched a terminal failure. + Task AwaitAckedFsnAsync(long targetFsn, TimeSpan timeout, CancellationToken ct = default); + + // ColumnDecimal64 / ColumnDecimal128 / ColumnDecimal256 (both the (decimal, scale) and raw-limb + // overloads) are declared on ISender and inherited here; QWP encodes them as fixed-width 8/16/32-byte + // mantissas with the column scale locked on the first non-null write. + + /// Append a BINARY value to the named column (opaque bytes; no UTF-8 contract). + IQwpWebSocketSender ColumnBinary(ReadOnlySpan name, ReadOnlySpan value); + + /// Append an IPv4 address to the named column. + IQwpWebSocketSender ColumnIPv4(ReadOnlySpan name, System.Net.IPAddress addr); + + /// Append a BYTE value (signed 8-bit integer) to the named column. + IQwpWebSocketSender ColumnByte(ReadOnlySpan name, sbyte value); + + /// Append a SHORT value (signed 16-bit integer) to the named column. + IQwpWebSocketSender ColumnShort(ReadOnlySpan name, short value); + + /// Append a FLOAT value (32-bit IEEE-754) to the named column. + IQwpWebSocketSender ColumnFloat(ReadOnlySpan name, float value); + + /// Append a DATE value as milliseconds since the Unix epoch to the named column. + IQwpWebSocketSender ColumnDate(ReadOnlySpan name, long millisSinceEpoch); + + /// + /// Append a GEOHASH value to the named column. (1–60) is + /// locked on the first non-null write; carries that many low bits. + /// + IQwpWebSocketSender ColumnGeohash(ReadOnlySpan name, ulong hash, int precisionBits); + + /// Append a LONG256 value (256-bit unsigned integer) to the named column. + IQwpWebSocketSender ColumnLong256(ReadOnlySpan name, System.Numerics.BigInteger value); + + /// + /// Number of notifications dropped because the + /// async error inbox was full. Non-zero indicates the user-supplied error_handler can't + /// keep up with the error rate. SF mode only; 0 otherwise. + /// + long DroppedErrorNotifications { get; } + + /// + /// Number of notifications dropped because the + /// listener inbox was full. Non-zero indicates the registered + /// can't keep up. 0 when no listener is + /// registered. + /// + long DroppedConnectionNotifications { get; } + + /// + /// Total notifications delivered to the + /// user-supplied (or default) error_handler. SF mode only; 0 otherwise. + /// + long TotalErrorNotificationsDelivered { get; } + + /// Total frames sent. + long TotalFramesSent { get; } + + /// Total OK ack frames received. Excludes durable-ack and error frames. + long TotalAcks { get; } + + /// Total server error frames received. + long TotalServerErrors { get; } + + /// Total connect attempts (succeeded plus failed). + long TotalReconnectAttempts { get; } + + /// Total successful connects. + long TotalReconnectsSucceeded { get; } +} diff --git a/src/net-questdb-client/Senders/ISender.cs b/src/net-questdb-client/Senders/ISender.cs index 9e6a8d18..65c7b1cd 100644 --- a/src/net-questdb-client/Senders/ISender.cs +++ b/src/net-questdb-client/Senders/ISender.cs @@ -28,12 +28,22 @@ namespace QuestDB.Senders; /// -/// Interface representing implementations. +/// Interface representing implementations. For ws:: / wss:: +/// senders prefer await using var so the close-time ACK drain doesn't block the caller. /// -public interface ISender : IDisposable +public interface ISender : IDisposable, IAsyncDisposable { + ValueTask IAsyncDisposable.DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + /// - /// Represents the current length of the buffer in UTF-8 bytes. + /// Approximate buffer size in bytes. For HTTP/TCP (ILP text) this is the exact UTF-8 + /// byte count of the pending payload. For WS/WSS (QWP columnar) this is an estimated + /// footprint of the per-column buffers — close to but not identical to the wire size, + /// because schema/symbol-dictionary deltas are added at flush time. /// public int Length { get; } @@ -88,10 +98,6 @@ public interface ISender : IDisposable /// /// /// Only usable outside of a transaction. If there are no pending rows, then this is a no-op. - ///
- /// If the is HTTP, this will return request and response information. - ///
- /// If the is TCP, this will return nulls. ///
/// When the request fails. public Task SendAsync(CancellationToken ct = default); @@ -442,6 +448,82 @@ public ISender NullableColumn(ReadOnlySpan name, decimal? value) return this; } + /// + /// Adds a DECIMAL64 column, coercing to + /// (rounded half away from zero). On ws:: / wss:: (QWP) this writes a fixed + /// 8-byte mantissa and locks the column scale on the first non-null write. On HTTP/TCP (ILP) + /// it writes the variable-length binary decimal and requires protocol version 3. + /// + /// The column name. + /// The value, rounded half away from zero to digits. + /// Number of fractional digits (0–18). + /// The sender instance for fluent call chaining. + /// If is out of range or the value's integer part overflows the type width. + public ISender ColumnDecimal64(ReadOnlySpan name, decimal value, byte scale); + + /// + /// Adds a DECIMAL128 column, coercing to + /// (rounded half away from zero). Same transport behaviour as + /// with a 16-byte QWP mantissa. + /// Over ILP, values whose scale exceeds 28 or whose magnitude exceeds 's + /// 96-bit mantissa are rejected — use ws:: / wss:: for the full range. + /// + /// The column name. + /// The value, rounded half away from zero to digits. + /// Number of fractional digits (0–38). + public ISender ColumnDecimal128(ReadOnlySpan name, decimal value, byte scale); + + /// + /// Adds a DECIMAL256 column, coercing to + /// (rounded half away from zero). Same transport behaviour as + /// with a 32-byte QWP mantissa. + /// Over ILP, values whose scale exceeds 28 or whose magnitude exceeds 's + /// 96-bit mantissa are rejected — use ws:: / wss:: for the full range. + /// + /// The column name. + /// The value, rounded half away from zero to digits. + /// Number of fractional digits (0–76). + public ISender ColumnDecimal256(ReadOnlySpan name, decimal value, byte scale); + + /// + /// Adds a DECIMAL64 column from the raw unscaled int64 mantissa with an explicit + /// . Exposes the full 18-digit range that + /// cannot always represent. Over ILP this is reconstructed as a and + /// requires protocol version 3. + /// + /// The column name. + /// The unscaled integer mantissa; the value is unscaledValue / 10^scale. + /// Number of fractional digits (0–18). + public ISender ColumnDecimal64(ReadOnlySpan name, long unscaledValue, byte scale); + + /// + /// Adds a DECIMAL128 column from the two two's-complement 64-bit limbs of the unscaled integer: + /// is the low 64 bits (unsigned magnitude), the + /// signed high 64 bits — i.e. (hi ≪ 64) | (ulong)lo. On ws:: / wss:: this + /// uses the full 38-digit range; over ILP values beyond 's 96-bit + /// mantissa or scale 28 are rejected. + /// + /// The column name. + /// Low 64 bits of the unscaled integer (unsigned magnitude). + /// High 64 bits of the unscaled integer (signed). + /// Number of fractional digits (0–38). + public ISender ColumnDecimal128(ReadOnlySpan name, long lo, long hi, byte scale); + + /// + /// Adds a DECIMAL256 column from the four two's-complement 64-bit limbs of the unscaled integer: + /// l0l2 are unsigned magnitude limbs and l3 is the signed high limb — + /// i.e. (ulong)l0 | (ulong)l1≪64 | (ulong)l2≪128 | l3≪192. On ws:: / wss:: + /// this uses the full 76-digit range; over ILP values beyond 's + /// 96-bit mantissa or scale 28 are rejected. + /// + /// The column name. + /// Bits 0–63 of the unscaled integer (unsigned magnitude limb). + /// Bits 64–127 of the unscaled integer (unsigned magnitude limb). + /// Bits 128–191 of the unscaled integer (unsigned magnitude limb). + /// Bits 192–255 of the unscaled integer (signed high limb). + /// Number of fractional digits (0–76). + public ISender ColumnDecimal256(ReadOnlySpan name, long l0, long l1, long l2, long l3, byte scale); + /// /// Adds a GUID column with the specified name and value to the current row. /// diff --git a/src/net-questdb-client/Senders/ISenderConnectionListener.cs b/src/net-questdb-client/Senders/ISenderConnectionListener.cs new file mode 100644 index 00000000..dacb2678 --- /dev/null +++ b/src/net-questdb-client/Senders/ISenderConnectionListener.cs @@ -0,0 +1,48 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Senders; + +/// +/// User-supplied callback invoked when the QWP ingest client observes a connection-state +/// transition. Registered via SenderOptions.ConnectionListener. +/// +/// +/// Threading. Implementations are invoked on a dedicated daemon dispatcher thread, never +/// on the I/O thread or the producer thread. Slow listeners cannot stall publishing or +/// reconnect; if the bounded inbox fills up, surplus events are dropped (visible via +/// ). +/// +/// Exceptions. Any exception thrown by the listener is caught and traced. The dispatcher +/// and the sender continue running. +/// +/// Delivery. Best-effort and rare relative to the data path. Success transitions +/// (, ) +/// are guaranteed to fire. Failure events may be coalesced under inbox pressure. +/// +public interface ISenderConnectionListener +{ + /// Invoked once per observed connection-state transition. + void OnEvent(SenderConnectionEvent evt); +} diff --git a/src/net-questdb-client/Senders/QwpWebSocketSender.cs b/src/net-questdb-client/Senders/QwpWebSocketSender.cs new file mode 100644 index 00000000..2319e4f4 --- /dev/null +++ b/src/net-questdb-client/Senders/QwpWebSocketSender.cs @@ -0,0 +1,1354 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +#if NET7_0_OR_GREATER + +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; +using QuestDB.Utils; + +namespace QuestDB.Senders; + +/// +/// ISender implementation backed by the WebSocket transport and QWP v1 columnar binary protocol. +/// +/// +/// The sender always routes frames through regardless of +/// whether sf_dir is set. With sf_dir, segments are mmap'd files and survive +/// restarts; without, segments are RAM only. Both modes pipeline async I/O, replay un-acked +/// frames on transient WS failures, and only terminate on permanent errors (auth, upgrade +/// reject, protocol violation, or reconnect-budget exhaustion). +/// +internal sealed class QwpWebSocketSender : IQwpWebSocketSender +{ + private const long TicksPerMicrosecond = 10L; + private const int EncoderInitialCapacity = 1 << 16; + + private readonly Dictionary _tables = new(StringComparer.Ordinal); +#if NET9_0_OR_GREATER + private readonly Dictionary.AlternateLookup> _tablesLookup; +#endif + private readonly QwpSymbolDictionary _symbolDictionary; + private readonly List _flushBatch = new(); + private readonly QwpEncoder.FrameBuilder _encoderBuffer; + + private readonly QwpSlotLock? _slotLock; + private readonly QwpCursorSendEngine _engine; + private readonly QwpBackgroundDrainerPool? _drainerPool; + private readonly QwpSenderErrorDispatcher? _errorDispatcher; + private readonly QwpConnectionEventDispatcher? _connectionEventDispatcher; + private readonly QwpTlsAuth.CertificateValidator? _certValidator; + + private readonly Dictionary _committedSeqTxn = new(StringComparer.Ordinal); + private readonly Dictionary _durableSeqTxn = new(StringComparer.Ordinal); + private readonly object _seqTxnLock = new(); + + private readonly bool _convertLocalToUtc; + + private QwpTableBuffer? _currentTable; + private int _disposed; + private int _runningRowCount; + private long _pendingBytes; + private long _currentTableSnapshotBytes; + private int _currentBatchMaxSymbolId = -1; + + // Transactional (defer-commit) mode: auto-flush frames carry FLAG_DEFER_COMMIT; an explicit + // commit ships a non-deferred frame. _hasDeferredMessages tracks whether a commit is owed. + private readonly bool _transactional; + private bool _hasDeferredMessages; + + public QwpWebSocketSender(SenderOptions options) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + if (!options.IsWebSocket()) + { + throw new IngressError(ErrorCode.ConfigError, + $"protocol must be ws or wss for {nameof(QwpWebSocketSender)}, got {options.protocol}"); + } + + _convertLocalToUtc = options.convert_local_to_utc; + _transactional = options.transaction; + + _symbolDictionary = new QwpSymbolDictionary(); +#if NET9_0_OR_GREATER + _tablesLookup = _tables.GetAlternateLookup>(); +#endif + _encoderBuffer = new QwpEncoder.FrameBuilder(EncoderInitialCapacity); + + (_slotLock, _engine, _drainerPool, _errorDispatcher, _connectionEventDispatcher, _certValidator) = BuildEngineStack(options); + _engine.SetTableEntryHandler(UpdateSeqTxnFromAck); + } + + private static (QwpSlotLock? slotLock, QwpCursorSendEngine engine, QwpBackgroundDrainerPool? pool, QwpSenderErrorDispatcher? dispatcher, QwpConnectionEventDispatcher? eventDispatcher, QwpTlsAuth.CertificateValidator? certValidator) + BuildEngineStack(SenderOptions options) + { + var sfMode = !string.IsNullOrEmpty(options.sf_dir); + QwpSlotLock? slotLock = null; + QwpSegmentRing? ring = null; + QwpAckWatermark? ackWatermark = null; + QwpCursorSendEngine? engine = null; + QwpBackgroundDrainerPool? pool = null; + QwpSenderErrorDispatcher? dispatcher = null; + QwpConnectionEventDispatcher? eventDispatcher = null; + QwpTlsAuth.CertificateValidator? certValidator = null; + + try + { + if (sfMode) + { + var sfRoot = options.sf_dir!; + var slotDir = Path.Combine(sfRoot, options.sender_id); + slotLock = QwpSlotLock.Acquire(slotDir); + ring = QwpSegmentRing.Open(slotDir, segmentCapacity: options.sf_max_bytes); + if (ring.NextFsn == 0) + { + // Clear any stale watermark from a prior session that left no segments behind. + QwpAckWatermark.RemoveOrphan(slotDir); + } + ackWatermark = QwpAckWatermark.Open(slotDir); + } + else + { + ring = QwpSegmentRing.OpenMemoryBacked(segmentCapacity: options.sf_max_bytes); + } + + var authHeader = BuildAuthHeader(options); + certValidator = BuildCertificateValidator(options); + var certCallback = certValidator?.Callback; + var tracker = new QwpHostHealthTracker(options.addresses, clientZone: options.zone, targetIsPrimary: false); + var transportFactory = BuildHostRotatingFactory(options, tracker, authHeader, certCallback); + + var policy = new QwpReconnectPolicy( + options.reconnect_initial_backoff_millis, + options.reconnect_max_backoff_millis, + options.reconnect_max_duration_millis, + jitter: QwpReconnectPolicy.EqualJitter); + + dispatcher = new QwpSenderErrorDispatcher(options.error_handler, options.error_inbox_capacity); + + if (options.ConnectionListener is not null) + { + eventDispatcher = new QwpConnectionEventDispatcher( + options.ConnectionListener, + options.connection_listener_inbox_capacity); + } + + var capturedSink = eventDispatcher; + engine = new QwpCursorSendEngine( + slotLock, + ring, + transportFactory, + policy, + options.sf_append_deadline_millis, + options.initial_connect_mode, + maxTotalBytes: options.sf_max_total_bytes, + skipBackoffPredicate: () => !tracker.IsRoundExhausted, + errorDispatcher: dispatcher, + policyResolver: options.BuildEffectivePolicyResolver(), + durableAckMode: options.request_durable_ack, + ackWatermark: ackWatermark, + connectionEventSink: capturedSink is null ? null : (Action)(evt => capturedSink.Offer(evt))); + + engine.Start(); + + if (options.initial_connect_mode != InitialConnectMode.async) + { + try + { + engine.FirstConnectTask.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + var code = (ex as IngressError)?.code ?? ErrorCode.SocketError; + throw new IngressError(code, + $"first connect failed against all {options.AddressCount} configured endpoint(s): {ex.Message}", ex); + } + } + + if (sfMode && options.drain_orphans) + { + var drainer = new QwpBackgroundDrainer( + contextBuilder: () => + { + var drainerTracker = new QwpHostHealthTracker(options.addresses, clientZone: options.zone, targetIsPrimary: false); + var drainerFactory = BuildHostRotatingFactory( + options, drainerTracker, authHeader, certCallback); + return new Qwp.Sf.DrainContext( + drainerFactory, + () => !drainerTracker.IsRoundExhausted); + }, + policy, + segmentCapacity: options.sf_max_bytes, + drainTimeout: options.reconnect_max_duration_millis, + durableAckMode: options.request_durable_ack); + // Orphan-drainer shutdown uses the pool's own small fixed grace; do NOT pass + // close_flush_timeout here, or a wedged drainer adds the full flush budget to Dispose(). + pool = new QwpBackgroundDrainerPool( + options.max_background_drainers, + drainer); + var orphans = QwpOrphanScanner.ClaimOrphans(options.sf_dir!, options.sender_id); + var enqueued = 0; + try + { + for (; enqueued < orphans.Count; enqueued++) + { + pool.Enqueue(orphans[enqueued]); + } + } + catch + { + for (var i = enqueued; i < orphans.Count; i++) + { + SfCleanup.Dispose(orphans[i]); + } + throw; + } + } + + return (slotLock, engine, pool, dispatcher, eventDispatcher, certValidator); + } + catch (Exception) + { + SfCleanup.Dispose(pool); + SfCleanup.Dispose(engine); + SfCleanup.Dispose(dispatcher); + SfCleanup.Dispose(eventDispatcher); + SfCleanup.Dispose(ring); + SfCleanup.Dispose(ackWatermark); + SfCleanup.Dispose(slotLock); + SfCleanup.Dispose(certValidator); + throw; + } + } + + /// + public SenderOptions Options { get; } + + /// + public int Length => (int)Math.Min(_pendingBytes, int.MaxValue); + + /// + public int RowCount => _runningRowCount; + + /// + public bool WithinTransaction => _transactional; + + /// + public DateTime LastFlush { get; private set; } = DateTime.MinValue; + private long _lastFlushTickCount; + + /// + public ISender Transaction(ReadOnlySpan tableName) + { + // WS transactions are a connection-level mode, not a per-table begin: enable `transaction=on` + // and commit with Commit()/Send(). + throw new IngressError(ErrorCode.InvalidApiCall, + "explicit per-table Transaction() is not supported on the WebSocket transport; set `transaction=on` and use Commit()/Send()"); + } + + /// + public void Rollback() + { + // Deferred rows are already streamed to the server (appended to the WAL, awaiting commit), so + // there is nothing to roll back client-side. + throw new IngressError(ErrorCode.InvalidApiCall, + "Rollback() is not supported on the WebSocket transport; deferred rows already shipped to the server cannot be rolled back"); + } + + /// + public Task CommitAsync(CancellationToken ct = default) + { + ThrowIfTerminal(); + RequireTransactionalMode(); + EnsureNoRowInProgress(); + return FlushAsyncCore(deferCommit: false, ct).AsTask(); + } + + /// + public void Commit(CancellationToken ct = default) + { + ThrowIfTerminal(); + RequireTransactionalMode(); + EnsureNoRowInProgress(); + FlushSync(deferCommit: false, ct); + } + + private void RequireTransactionalMode() + { + if (!_transactional) + { + throw new IngressError(ErrorCode.InvalidApiCall, + "no transaction to commit; set `transaction=on` to enable WebSocket transactional mode"); + } + } + + /// + public ISender Table(ReadOnlySpan name) + { + ThrowIfTerminal(); +#if NET9_0_OR_GREATER + if (!_tablesLookup.TryGetValue(name, out var t)) + { + var key = name.ToString(); + t = new QwpTableBuffer(key, Options.max_name_len); + _tables[key] = t; + } +#else + var key = name.ToString(); + if (!_tables.TryGetValue(key, out var t)) + { + t = new QwpTableBuffer(key, Options.max_name_len); + _tables[key] = t; + } +#endif + _currentTable = t; + _currentTableSnapshotBytes = t.GetBufferedBytes(); + return this; + } + + /// + public ISender Symbol(ReadOnlySpan name, ReadOnlySpan value) + { + ThrowIfTerminal(); + var preCount = _symbolDictionary.Count; + try + { + // Add validates a first-seen value's UTF-8 (a lone surrogate would otherwise throw from + // the encoder on every self-sufficient flush and wedge the sender). Repeated values take + // the dictionary's fast path, so the hot path stays free of any extra scan. + var globalId = _symbolDictionary.Add(value); + EnsureCurrentTable().AppendSymbol(name, globalId); + if (globalId > _currentBatchMaxSymbolId) + { + _currentBatchMaxSymbolId = globalId; + } + } + catch + { + // Drop any dict entry Add committed and abandon the in-progress row, so a rejected + // value/name never leaves the buffer half-written. CancelCurrentRow is idempotent, so it + // is safe even when AppendSymbol already cancelled on a bad name. + if (_symbolDictionary.Count > preCount) + { + _symbolDictionary.RollbackTo(preCount); + } + _currentTable?.CancelCurrentRow(); + throw; + } + return this; + } + + /// + public ISender Column(ReadOnlySpan name, ReadOnlySpan value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendVarchar(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, long value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendLong(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, int value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendInt(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, bool value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendBool(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, double value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDouble(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, DateTime value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendTimestampMicros(name, DateTimeToMicros(value)); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, DateTimeOffset value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendTimestampMicros(name, DateTimeToMicros(value.UtcDateTime)); + return this; + } + + /// + public ISender ColumnNanos(ReadOnlySpan name, long timestampNanos) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendTimestampNanos(name, timestampNanos); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, decimal value) + { + // QWP carries an explicit per-column decimal width + scale, so the scale-inferring overload is + // not supported here — the caller must choose the type and scale. + throw new IngressError(ErrorCode.InvalidApiCall, + "QWP requires an explicit decimal type and scale; use ColumnDecimal64/128/256(name, value, scale)"); + } + + /// + public ISender Column(ReadOnlySpan name, Guid value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendUuid(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, char value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendChar(name, value); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, ReadOnlySpan value) where T : struct + { + // Reuse the shape-aware overload with a 1D shape; lets the array path do the type dispatch. + Span shape = stackalloc int[1]; + shape[0] = value.Length; + AppendArrayDispatch(name, value, shape); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, IEnumerable value, IEnumerable shape) where T : struct + { + ThrowIfTerminal(); + var arr = value as T[] ?? value.ToArray(); + var shapeArr = shape as int[] ?? shape.ToArray(); + AppendArrayDispatch(name, arr.AsSpan(), shapeArr.AsSpan()); + return this; + } + + /// + public ISender Column(ReadOnlySpan name, Array value) + { + ThrowIfTerminal(); + + var rank = value.Rank; + if (rank < 1 || rank > QwpConstants.MaxArrayDimensions) + { + throw new IngressError(ErrorCode.InvalidArrayShapeError, + $"array rank {rank} not supported; must be in [1, {QwpConstants.MaxArrayDimensions}]"); + } + + Span shape = stackalloc int[rank]; + for (var i = 0; i < rank; i++) + { + shape[i] = value.GetLength(i); + } + + var elementType = value.GetType().GetElementType(); + if (elementType == typeof(double)) + { + ref var head = ref System.Runtime.InteropServices.MemoryMarshal.GetArrayDataReference(value); + var bytes = System.Runtime.InteropServices.MemoryMarshal.CreateSpan(ref head, value.Length * sizeof(double)); + var flat = System.Runtime.InteropServices.MemoryMarshal.Cast(bytes); + EnsureCurrentTable().AppendDoubleArray(name, flat, shape); + } + else if (elementType == typeof(long)) + { + ref var head = ref System.Runtime.InteropServices.MemoryMarshal.GetArrayDataReference(value); + var bytes = System.Runtime.InteropServices.MemoryMarshal.CreateSpan(ref head, value.Length * sizeof(long)); + var flat = System.Runtime.InteropServices.MemoryMarshal.Cast(bytes); + EnsureCurrentTable().AppendLongArray(name, flat, shape); + } + else + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"array element type {elementType} not supported; only double and long"); + } + + return this; + } + + /// + public ISender ColumnDecimal64(ReadOnlySpan name, decimal value, byte scale) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal64(name, value, scale); + return this; + } + + /// + public ISender ColumnDecimal128(ReadOnlySpan name, decimal value, byte scale) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal128(name, value, scale); + return this; + } + + /// + public ISender ColumnDecimal256(ReadOnlySpan name, decimal value, byte scale) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal256(name, value, scale); + return this; + } + + /// + public ISender ColumnDecimal64(ReadOnlySpan name, long unscaledValue, byte scale) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal64(name, unscaledValue, scale); + return this; + } + + /// + public ISender ColumnDecimal128(ReadOnlySpan name, long lo, long hi, byte scale) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal128(name, lo, hi, scale); + return this; + } + + /// + public ISender ColumnDecimal256(ReadOnlySpan name, long l0, long l1, long l2, long l3, byte scale) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal256(name, l0, l1, l2, l3, scale); + return this; + } + + /// Appends a BINARY value (opaque bytes; same wire layout as VARCHAR but no UTF-8 contract). + public IQwpWebSocketSender ColumnBinary(ReadOnlySpan name, ReadOnlySpan value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendBinary(name, value); + return this; + } + + /// Appends an IPv4 address. Throws if is not . + public IQwpWebSocketSender ColumnIPv4(ReadOnlySpan name, System.Net.IPAddress addr) + { + ArgumentNullException.ThrowIfNull(addr); + if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"IPv4 column requires an InterNetwork address, got {addr.AddressFamily} (`{addr}`)"); + } + + ThrowIfTerminal(); + Span octets = stackalloc byte[4]; + if (!addr.TryWriteBytes(octets, out var written) || written != 4) + { + throw new IngressError(ErrorCode.InvalidApiCall, $"failed to serialise IPv4 address `{addr}`"); + } + + var packed = System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(octets); + EnsureCurrentTable().AppendIPv4(name, packed); + return this; + } + + /// + public IQwpWebSocketSender ColumnByte(ReadOnlySpan name, sbyte value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendByte(name, value); + return this; + } + + /// + public IQwpWebSocketSender ColumnShort(ReadOnlySpan name, short value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendShort(name, value); + return this; + } + + /// + public IQwpWebSocketSender ColumnFloat(ReadOnlySpan name, float value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendFloat(name, value); + return this; + } + + /// + public IQwpWebSocketSender ColumnDate(ReadOnlySpan name, long millisSinceEpoch) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDateMillis(name, millisSinceEpoch); + return this; + } + + /// + public IQwpWebSocketSender ColumnGeohash(ReadOnlySpan name, ulong hash, int precisionBits) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendGeohash(name, hash, precisionBits); + return this; + } + + /// + public IQwpWebSocketSender ColumnLong256(ReadOnlySpan name, System.Numerics.BigInteger value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendLong256(name, value); + return this; + } + + private void AppendArrayDispatch(ReadOnlySpan name, ReadOnlySpan values, ReadOnlySpan shape) + where T : struct + { + ThrowIfTerminal(); + var col = EnsureCurrentTable(); + if (typeof(T) == typeof(double)) + { + col.AppendDoubleArray(name, System.Runtime.InteropServices.MemoryMarshal.Cast(values), shape); + } + else if (typeof(T) == typeof(long)) + { + col.AppendLongArray(name, System.Runtime.InteropServices.MemoryMarshal.Cast(values), shape); + } + else + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"array element type {typeof(T)} not supported; only double and long"); + } + } + + /// + public ValueTask AtAsync(DateTime value, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + var t = EnsureCurrentTable(); + GuardRowSize(t); + t.At(DateTimeToMicros(value)); + OnRowCommitted(t); + return FlushIfNecessaryAsyncCore(ct); + } + + /// + public ValueTask AtAsync(DateTimeOffset value, CancellationToken ct = default) + => AtAsync(value.UtcDateTime, ct); + + /// + public ValueTask AtAsync(long value, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + var t = EnsureCurrentTable(); + GuardRowSize(t); + t.At(value); + OnRowCommitted(t); + return FlushIfNecessaryAsyncCore(ct); + } + + /// + public ValueTask AtNowAsync(CancellationToken ct = default) + => AtAsync(DateTime.UtcNow, ct); + + /// + public ValueTask AtNanosAsync(long timestampNanos, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + var t = EnsureCurrentTable(); + GuardRowSize(t); + t.AtNanos(timestampNanos); + OnRowCommitted(t); + return FlushIfNecessaryAsyncCore(ct); + } + + /// + public void At(DateTime value, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + var t = EnsureCurrentTable(); + GuardRowSize(t); + t.At(DateTimeToMicros(value)); + OnRowCommitted(t); + FlushIfNecessary(ct); + } + + /// + public void At(DateTimeOffset value, CancellationToken ct = default) + { + At(value.UtcDateTime, ct); + } + + /// + public void At(long value, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + var t = EnsureCurrentTable(); + GuardRowSize(t); + t.At(value); + OnRowCommitted(t); + FlushIfNecessary(ct); + } + + /// + public void AtNow(CancellationToken ct = default) + { + At(DateTime.UtcNow, ct); + } + + /// + public void AtNanos(long timestampNanos, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + var t = EnsureCurrentTable(); + GuardRowSize(t); + t.AtNanos(timestampNanos); + OnRowCommitted(t); + FlushIfNecessary(ct); + } + + /// + public Task SendAsync(CancellationToken ct = default) + { + ThrowIfTerminal(); + EnsureNoRowInProgress(); + return FlushAsyncCore(deferCommit: false, ct).AsTask(); + } + + /// + public void Send(CancellationToken ct = default) + { + ThrowIfTerminal(); + EnsureNoRowInProgress(); + FlushSync(deferCommit: false, ct); + } + + private void EnsureNoRowInProgress() + { + foreach (var t in _tables.Values) + { + if (t.HasPendingRow) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"row in progress on table `{t.TableName}` — call At()/AtNow() to commit or CancelRow() to abandon before flushing"); + } + } + } + + private int EncodeBatch(bool deferCommit) + { + _flushBatch.Clear(); + foreach (var t in _tables.Values) + { + if (t.RowCount > 0) + { + _flushBatch.Add(t); + } + } + + if (_flushBatch.Count == 0) + { + return 0; + } + + return QwpEncoder.EncodeInto( + _encoderBuffer, _flushBatch, _symbolDictionary, + selfSufficient: true, + symbolDeltaCount: _currentBatchMaxSymbolId + 1, + deferCommit: deferCommit); + } + + // An empty (zero-table) non-deferred frame that triggers the server-side commit of rows shipped by + // earlier deferred auto-flushes when an explicit commit has no residual rows to send. Mirrors the + // Java client's sendCommitMessage (beginMessage with tableCount=0). + private int EncodeCommitFrame() + { + return QwpEncoder.EncodeInto( + _encoderBuffer, Array.Empty(), _symbolDictionary, + selfSufficient: true, + symbolDeltaCount: _currentBatchMaxSymbolId + 1, + deferCommit: false); + } + + private void FlushSync(bool deferCommit, CancellationToken ct) + { + var len = EncodeBatch(deferCommit); + if (len > 0) + { + GuardBatchSize(len); + _engine.AppendBlocking(_encoderBuffer.AsSpan(0, len), ct); + OnFlushSucceeded(); + _hasDeferredMessages = deferCommit; + return; + } + + // No buffered rows. If this is a commit and earlier auto-flushes deferred, send the commit frame. + if (!deferCommit && _hasDeferredMessages) + { + var commitLen = EncodeCommitFrame(); + _engine.AppendBlocking(_encoderBuffer.AsSpan(0, commitLen), ct); + OnFlushSucceeded(); + _hasDeferredMessages = false; + } + } + + private async ValueTask FlushAsyncCore(bool deferCommit, CancellationToken ct) + { + var len = EncodeBatch(deferCommit); + if (len > 0) + { + GuardBatchSize(len); + await _engine.AppendAsync(_encoderBuffer.WrittenMemory, ct).ConfigureAwait(false); + OnFlushSucceeded(); + _hasDeferredMessages = deferCommit; + return; + } + + if (!deferCommit && _hasDeferredMessages) + { + EncodeCommitFrame(); + await _engine.AppendAsync(_encoderBuffer.WrittenMemory, ct).ConfigureAwait(false); + OnFlushSucceeded(); + _hasDeferredMessages = false; + } + } + + /// + public long AckedFsn => _engine.AckedFsn - 1; + + /// + public async Task FlushAndGetSequenceAsync(CancellationToken ct = default) + { + ThrowIfTerminal(); + // An explicit, sequence-returning flush is a commit point (non-deferred). + var len = EncodeBatch(deferCommit: false); + if (len == 0) + { + if (!_hasDeferredMessages) return _engine.NextFsn - 1; + EncodeCommitFrame(); + await _engine.AppendAsync(_encoderBuffer.WrittenMemory, ct).ConfigureAwait(false); + var committedFsn = _engine.NextFsn - 1; + OnFlushSucceeded(); + _hasDeferredMessages = false; + return committedFsn; + } + GuardBatchSize(len); + await _engine.AppendAsync(_encoderBuffer.WrittenMemory, ct).ConfigureAwait(false); + var publishedFsn = _engine.NextFsn - 1; + OnFlushSucceeded(); + _hasDeferredMessages = false; + return publishedFsn; + } + + /// + public Task AwaitAckedFsnAsync(long targetFsn, TimeSpan timeout, CancellationToken ct = default) + { + if (targetFsn < 0) return Task.FromResult(true); + ThrowIfTerminal(); + return _engine.AwaitAckedFsnAsync(targetFsn + 1, timeout, ct); + } + + private void OnFlushSucceeded() + { + ResetPendingState(); + LastFlush = DateTime.UtcNow; + _lastFlushTickCount = Environment.TickCount64; + } + + private void ResetPendingState() + { + // Symbol ids must stay stable for the connection's lifetime; resetting the + // dictionary per flush makes the server serve stale symbol-cache hits. + _currentBatchMaxSymbolId = -1; + foreach (var t in _flushBatch) + { + t.Clear(); + } + + _flushBatch.Clear(); + _runningRowCount = 0; + _pendingBytes = 0; + _currentTableSnapshotBytes = 0; + _currentTable = null; + } + + /// + public void Truncate() + { + ThrowIfTerminal(); + foreach (var t in _tables.Values) + { + t.TrimToCurrent(); + } + } + + /// + public void CancelRow() + { + ThrowIfTerminal(); + _currentTable?.CancelCurrentRow(); + } + + /// + public void Clear() + { + ThrowIfTerminal(); + foreach (var t in _tables.Values) + { + t.Clear(); + } + + _currentBatchMaxSymbolId = -1; + _currentTable = null; + _runningRowCount = 0; + _pendingBytes = 0; + _currentTableSnapshotBytes = 0; + } + + /// + public long GetHighestAckedSeqTxn(string tableName) + { + ArgumentNullException.ThrowIfNull(tableName); + ThrowIfTerminal(); + lock (_seqTxnLock) + { + return _committedSeqTxn.TryGetValue(tableName, out var v) ? v : -1L; + } + } + + /// + public long GetHighestDurableSeqTxn(string tableName) + { + ArgumentNullException.ThrowIfNull(tableName); + ThrowIfTerminal(); + lock (_seqTxnLock) + { + return _durableSeqTxn.TryGetValue(tableName, out var v) ? v : -1L; + } + } + + /// + public long DroppedErrorNotifications => _errorDispatcher?.DroppedNotifications ?? 0L; + + /// + public long DroppedConnectionNotifications => _connectionEventDispatcher?.DroppedNotifications ?? 0L; + + /// + public long TotalErrorNotificationsDelivered => _errorDispatcher?.TotalDelivered ?? 0L; + + /// + public long TotalFramesSent => _engine.TotalFramesSent; + + /// + public long TotalAcks => _engine.TotalAcks; + + /// + public long TotalServerErrors => _engine.TotalServerErrors; + + /// + public long TotalReconnectAttempts => _engine.TotalReconnectAttempts; + + /// + public long TotalReconnectsSucceeded => _engine.TotalReconnectsSucceeded; + + private void UpdateSeqTxnFromAck(QwpTableEntry entry, bool isDurable) + { + lock (_seqTxnLock) + { + if (isDurable) + { + if (!_durableSeqTxn.TryGetValue(entry.TableName, out var prev) || entry.SeqTxn > prev) + { + _durableSeqTxn[entry.TableName] = entry.SeqTxn; + } + } + else + { + if (!_committedSeqTxn.TryGetValue(entry.TableName, out var prev) || entry.SeqTxn > prev) + { + _committedSeqTxn[entry.TableName] = entry.SeqTxn; + } + } + } + } + + /// + public void Ping(CancellationToken ct = default) + => PingAsyncCore(ct).GetAwaiter().GetResult(); + + /// + public ValueTask PingAsync(CancellationToken ct = default) + => PingAsyncCore(ct); + + private async ValueTask PingAsyncCore(CancellationToken ct) + { + ThrowIfTerminal(); + await _engine.FlushAsync(Options.ping_timeout, ct).ConfigureAwait(false); + } + + /// + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + DisposeStackSync(); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + await DisposeStackAsync().ConfigureAwait(false); + } + + private void DisposeStackSync() + { + ExceptionDispatchInfo? toRethrow = null; + try + { + toRethrow = DrainOnClose( + encode: () => FlushSync(deferCommit: false, CancellationToken.None), + wait: () => _engine.FlushAsync(Options.close_flush_timeout_millis).GetAwaiter().GetResult()); + } + finally + { + SfCleanup.Dispose(_drainerPool); + SfCleanup.Dispose(_engine); + SfCleanup.Dispose(_errorDispatcher); + SfCleanup.Dispose(_connectionEventDispatcher); + SfCleanup.Dispose(_certValidator); + } + + toRethrow?.Throw(); + } + + private async ValueTask DisposeStackAsync() + { + ExceptionDispatchInfo? toRethrow = null; + try + { + toRethrow = await DrainOnCloseAsync( + encode: () => FlushAsyncCore(deferCommit: false, CancellationToken.None), + wait: () => new ValueTask(_engine.FlushAsync(Options.close_flush_timeout_millis))).ConfigureAwait(false); + } + finally + { + SfCleanup.Dispose(_drainerPool); + SfCleanup.Dispose(_engine); + SfCleanup.Dispose(_errorDispatcher); + SfCleanup.Dispose(_connectionEventDispatcher); + SfCleanup.Dispose(_certValidator); + } + + toRethrow?.Throw(); + } + + private ExceptionDispatchInfo? DrainOnClose(Action encode, Action wait) + { + var timeoutMs = Options.close_flush_timeout_millis.TotalMilliseconds; + if (timeoutMs <= 0) + { + // Explicit "don't wait" close. Still encode buffered rows into the on-disk ring in SF mode + // (a local append bounded by sf_append_deadline, not a network wait) so they replay after + // restart; best-effort and never throws. In RAM mode the ring is torn down un-sent, so + // there is nothing useful to encode. + if (!string.IsNullOrEmpty(Options.sf_dir)) + { + try { encode(); } + catch { } + } + return null; + } + + try + { + encode(); + wait(); + } + catch (TimeoutException ex) + { + LogCloseFlushTimeoutWarn(timeoutMs); + return CaptureTerminalForRethrow() ?? ExceptionDispatchInfo.Capture(ex); + } + catch { } + + return CaptureTerminalForRethrow(); + } + + private async ValueTask DrainOnCloseAsync(Func encode, Func wait) + { + var timeoutMs = Options.close_flush_timeout_millis.TotalMilliseconds; + if (timeoutMs <= 0) + { + // See DrainOnClose: encode best-effort into the on-disk ring in SF mode even when the + // ack-wait is skipped, so buffered rows survive to replay; never throws. + if (!string.IsNullOrEmpty(Options.sf_dir)) + { + try { await encode().ConfigureAwait(false); } + catch { } + } + return null; + } + + try + { + await encode().ConfigureAwait(false); + await wait().ConfigureAwait(false); + } + catch (TimeoutException ex) + { + LogCloseFlushTimeoutWarn(timeoutMs); + return CaptureTerminalForRethrow() ?? ExceptionDispatchInfo.Capture(ex); + } + catch { } + + return CaptureTerminalForRethrow(); + } + + private ExceptionDispatchInfo? CaptureTerminalForRethrow() + { + var terminal = _engine.TerminalError; + if (terminal is null) return null; + if (_errorDispatcher?.HasDeliveredToCustomHandler == true) return null; + return ExceptionDispatchInfo.Capture(terminal); + } + + private void LogCloseFlushTimeoutWarn(double timeoutMs) + { + Trace.TraceWarning( + "QWP close: close_flush_timeout ({0:F0} ms) expired with un-acked frames pending; pending data {1}.", + timeoutMs, + !string.IsNullOrEmpty(Options.sf_dir) ? "remains on disk (SF mode)" : "is lost (Memory mode)"); + } + + private QwpTableBuffer EnsureCurrentTable() + { + if (_currentTable is null) + { + throw new IngressError(ErrorCode.InvalidApiCall, "Table(...) must be called before adding columns or symbols"); + } + + return _currentTable; + } + + private void ThrowIfTerminal() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(QwpWebSocketSender)); + } + + if (_engine.IsTerminallyFailed) + { + var inner = _engine.TerminalError; + var code = (inner as IngressError)?.code ?? ErrorCode.ServerFlushError; + var msg = inner?.Message ?? "QWP cursor engine failed terminally"; + throw inner is null + ? new IngressError(code, msg) + : new IngressError(code, msg, inner); + } + } + + private void GuardLastFlushNotSet() + { + if (LastFlush == DateTime.MinValue) + { + LastFlush = DateTime.UtcNow; + _lastFlushTickCount = Environment.TickCount64; + } + } + + private void FlushIfNecessary(CancellationToken ct) + { + if (!ShouldAutoFlush()) return; + // In transactional mode auto-flush stages rows with FLAG_DEFER_COMMIT; the explicit + // Send()/Commit() commits. Outside transactional mode every frame commits immediately. + FlushSync(deferCommit: _transactional, ct); + } + + private ValueTask FlushIfNecessaryAsyncCore(CancellationToken ct) + { + if (!ShouldAutoFlush()) return ValueTask.CompletedTask; + return FlushAsyncCore(deferCommit: _transactional, ct); + } + + private bool ShouldAutoFlush() + { + if (Options.auto_flush != AutoFlushType.on) return false; + + var effectiveBytes = EffectiveAutoFlushBytes(); + var rowsTrigger = Options.auto_flush_rows > 0 && RowCount >= Options.auto_flush_rows; + var bytesTrigger = effectiveBytes > 0 && _pendingBytes >= effectiveBytes; + var timeTrigger = Options.auto_flush_interval > TimeSpan.Zero + && Environment.TickCount64 - _lastFlushTickCount >= (long)Options.auto_flush_interval.TotalMilliseconds; + return rowsTrigger || bytesTrigger || timeTrigger; + } + + // The size ceiling to clamp against: the server-advertised cap once known, otherwise the + // protocol hard limit. Until the first successful connect the server cap is 0 (unknown) — + // notably in initial_connect_mode=async, where rows are appended before connecting — so without + // this fallback the row/batch guards would be bypassed entirely in that window. + private int EffectiveMaxBatchCap() + { + var cap = _engine.NegotiatedMaxBatchSize; + return cap > 0 ? cap : QwpConstants.MaxBatchBytes; + } + + // Clamps the configured byte budget to fit under the batch cap minus a 10% margin for schema / + // dict-delta / framing overhead. auto_flush_bytes=off stays off even when a cap is advertised. + // Before the first connect the cap falls back to the protocol limit so auto-flush can't + // accumulate a batch the encoder will later reject (and leave stuck in the buffer). + private int EffectiveAutoFlushBytes() + { + var configured = Options.auto_flush_bytes; + if (configured <= 0) return 0; + var safeBudget = (long)EffectiveMaxBatchCap() * 9 / 10; + return configured < safeBudget ? configured : (int)safeBudget; + } + + private void GuardRowSize(QwpTableBuffer t) + { + var cap = _engine.NegotiatedMaxBatchSize; + var effectiveCap = cap > 0 ? cap : QwpConstants.MaxBatchBytes; + var rowBytes = t.GetBufferedBytes() - _currentTableSnapshotBytes; + // 10% margin matches EffectiveAutoFlushBytes: framing overhead pushes a raw-cap-sized row over. + var safeBudget = (long)effectiveCap * 9 / 10; + if (rowBytes <= safeBudget) return; + t.CancelCurrentRow(); + throw new IngressError(ErrorCode.InvalidApiCall, cap > 0 + ? $"row too large for server batch cap [rowBytes={rowBytes}, serverMaxBatchSize={cap}]" + : $"row too large for protocol batch limit [rowBytes={rowBytes}, " + + $"protocolMaxBatchSize={QwpConstants.MaxBatchBytes}, server cap not yet negotiated]"); + } + + private void OnRowCommitted(QwpTableBuffer t) + { + _runningRowCount++; + var bufferedNow = t.GetBufferedBytes(); + _pendingBytes += bufferedNow - _currentTableSnapshotBytes; + _currentTableSnapshotBytes = bufferedNow; + } + + private void GuardBatchSize(int messageSize) + { + var cap = _engine.NegotiatedMaxBatchSize; + if (cap <= 0 || messageSize <= cap) return; + var droppedRows = _runningRowCount; + ResetPendingState(); + throw new IngressError(ErrorCode.InvalidApiCall, + $"batch too large for server batch cap [messageSize={messageSize}, serverMaxBatchSize={cap}, droppedRows={droppedRows}]"); + } + + private long DateTimeToMicros(DateTime value) + { + var utc = _convertLocalToUtc && value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value; + return (utc - DateTime.UnixEpoch).Ticks / TicksPerMicrosecond; + } + + // Same authHeader is forwarded to every failover host. Safe because ErrorCode.AuthError is + // terminal in QwpCursorSendEngine; auth rejection never reaches the host-rotation path. + private static Func BuildHostRotatingFactory( + SenderOptions options, + QwpHostHealthTracker tracker, + string? authHeader, + System.Net.Security.RemoteCertificateValidationCallback? certValidator) + { + var proxy = ResolveProxy(options.proxy); + return () => + { + var idx = tracker.PickNext(); + if (idx < 0) + { + tracker.BeginRound(forgetClassifications: true); + idx = tracker.PickNext(); + } + + var keepalive = options.request_durable_ack + && options.durable_ack_keepalive_interval_millis > TimeSpan.Zero + ? options.durable_ack_keepalive_interval_millis + : TimeSpan.Zero; + + var transportOpts = new QwpWebSocketTransportOptions + { + Uri = options.BuildUri(idx, QwpConstants.WritePath), + AuthorizationHeader = authHeader, + RequestDurableAck = options.request_durable_ack, + RemoteCertificateValidationCallback = certValidator, + Proxy = proxy, + KeepAliveInterval = keepalive, + }; + + return new QwpTrackedCursorTransport(new QwpWebSocketTransport(transportOpts), tracker, idx, + options.auth_timeout); + }; + } + + private static System.Net.IWebProxy? ResolveProxy(string? proxy) + { + if (string.IsNullOrEmpty(proxy) || string.Equals(proxy, "disable", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + if (string.Equals(proxy, "system", StringComparison.OrdinalIgnoreCase)) + { + return System.Net.WebRequest.DefaultWebProxy; + } + if (!Uri.TryCreate(proxy, UriKind.Absolute, out var uri)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`proxy` must be `disable`, `system`, or an absolute URI; got `{proxy}`"); + } + return new System.Net.WebProxy(uri); + } + + private static string? BuildAuthHeader(SenderOptions options) => + QwpTlsAuth.BuildAuthHeader(options.username, options.password, options.token); + + private static QwpTlsAuth.CertificateValidator? BuildCertificateValidator(SenderOptions options) => + QwpTlsAuth.BuildCertificateValidator(options.tls_verify, options.tls_roots, options.tls_roots_password); +} + +#endif diff --git a/src/net-questdb-client/Senders/SenderConnectionEvent.cs b/src/net-questdb-client/Senders/SenderConnectionEvent.cs new file mode 100644 index 00000000..6959e3fb --- /dev/null +++ b/src/net-questdb-client/Senders/SenderConnectionEvent.cs @@ -0,0 +1,82 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Senders; + +/// +/// Immutable description of a single connection-state transition observed by the QWP ingest +/// client. Delivered to a registered via +/// SenderOptions.ConnectionListener. +/// +public sealed class SenderConnectionEvent +{ + /// Sentinel for / when not applicable. + public const long NoCounter = -1L; + + /// Sentinel for when not applicable. + public const int NoPort = -1; + + /// Constructs a new event. Internal callers populate every field; sentinels mark missing data. + public SenderConnectionEvent( + SenderConnectionEventKind kind, + string? host, + int port, + long attemptNumber, + long roundNumber, + Exception? cause, + DateTimeOffset timestamp) + { + Kind = kind; + Host = host; + Port = port; + AttemptNumber = attemptNumber; + RoundNumber = roundNumber; + Cause = cause; + Timestamp = timestamp; + } + + /// The kind of state transition. + public SenderConnectionEventKind Kind { get; } + + /// The endpoint host involved, or null when not applicable. + public string? Host { get; } + + /// The endpoint port involved, or when not applicable. + public int Port { get; } + + /// Reconnect-attempt counter snapshot, or for events without one. + public long AttemptNumber { get; } + + /// Sweep counter snapshot, or for events without one. + public long RoundNumber { get; } + + /// + /// Underlying cause for failure / terminal events; null for success transitions + /// (, ). + /// + public Exception? Cause { get; } + + /// Wall-clock time the event was generated. + public DateTimeOffset Timestamp { get; } +} diff --git a/src/net-questdb-client/Senders/SenderConnectionEventKind.cs b/src/net-questdb-client/Senders/SenderConnectionEventKind.cs new file mode 100644 index 00000000..e0c9ab83 --- /dev/null +++ b/src/net-questdb-client/Senders/SenderConnectionEventKind.cs @@ -0,0 +1,64 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +namespace QuestDB.Senders; + +/// +/// Categorisation of transitions observed by the QWP +/// ingest client. +/// +public enum SenderConnectionEventKind +{ + /// The very first successful connect of this sender's lifetime. + Connected, + + /// The active wire connection dropped and the reconnect loop is about to start. + Disconnected, + + /// A reconnect attempt succeeded (against any endpoint). + Reconnected, + + /// + /// A reconnect attempt succeeded against an endpoint different from the previously-active + /// one. Mutually exclusive with . + /// + FailedOver, + + /// A single endpoint connect or upgrade attempt failed; client will try the next. + EndpointAttemptFailed, + + /// Every endpoint in the configured address list was attempted and none accepted. + AllEndpointsUnreachable, + + /// + /// Terminal: server-rejected credentials. The sender will halt; the next producer-thread API + /// call surfaces an . + /// + AuthFailed, + + /// + /// Terminal: the configured reconnect time budget was exhausted without a successful reconnect. + /// + ReconnectBudgetExhausted, +} diff --git a/src/net-questdb-client/Senders/TcpSender.cs b/src/net-questdb-client/Senders/TcpSender.cs index eb8435c4..30567204 100644 --- a/src/net-questdb-client/Senders/TcpSender.cs +++ b/src/net-questdb-client/Senders/TcpSender.cs @@ -38,7 +38,7 @@ namespace QuestDB.Senders; /// /// An implementation of for TCP transport. /// -internal class TcpSender : AbstractSender +internal sealed class TcpSender : AbstractSender { private static readonly RemoteCertificateValidationCallback AllowAllCertCallback = (_, _, _, _) => true; private bool _authenticated; @@ -77,7 +77,8 @@ private void Build() Options.init_buf_size, Options.max_name_len, Options.max_buf_size, - Options.protocol_version == ProtocolVersion.Auto ? ProtocolVersion.V1 : Options.protocol_version + Options.protocol_version == ProtocolVersion.Auto ? ProtocolVersion.V1 : Options.protocol_version, + Options.convert_local_to_utc ); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); diff --git a/src/net-questdb-client/Utils/AddressProvider.cs b/src/net-questdb-client/Utils/AddressProvider.cs index d0cc8935..30f71078 100644 --- a/src/net-questdb-client/Utils/AddressProvider.cs +++ b/src/net-questdb-client/Utils/AddressProvider.cs @@ -91,28 +91,19 @@ public void RotateToNextAddress() _currentIndex = (_currentIndex + 1) % _addresses.Count; } - /// - /// Parses the host from an address string. - /// Supports both regular (host:port) and IPv6 ([ipv6]:port) formats. - /// For IPv6 addresses, returns the complete bracketed form including '[' and ']'. - /// + /// Parses the host from a host:port address string. public static string ParseHost(string address) { if (string.IsNullOrEmpty(address)) return address; - // Handle IPv6 addresses in bracket notation: [ipv6]:port - if (address.StartsWith("[")) + // Bracketed IPv6 literal: the host ends at ']'; an inner colon must not be mistaken for the port separator. + if (address[0] == '[') { - var closingBracketIndex = address.IndexOf(']'); - if (closingBracketIndex > 0) - { - // Return the entire bracketed section as the host - return address.Substring(0, closingBracketIndex + 1); - } + var closeBracket = address.IndexOf(']'); + return closeBracket > 0 ? address.Substring(0, closeBracket + 1) : address; } - // For non-bracketed addresses, use the last colon to split host and port var colonIndex = address.LastIndexOf(':'); if (colonIndex > 0) { @@ -122,37 +113,28 @@ public static string ParseHost(string address) return address; } - /// - /// Parses the port from an address string. - /// Supports both regular (host:port) and IPv6 ([ipv6]:port) formats. - /// Returns -1 if no port is specified. - /// + /// Parses the port from a host:port address. Returns -1 if absent. public static int ParsePort(string address) { if (string.IsNullOrEmpty(address)) return -1; - // Handle IPv6 addresses in bracket notation: [ipv6]:port - if (address.StartsWith("[")) + int colonIndex; + if (address[0] == '[') { - var closingBracketIndex = address.IndexOf(']'); - if (closingBracketIndex > 0 && closingBracketIndex < address.Length - 1) - { - // Check if there's a colon after the closing bracket - if (address[closingBracketIndex + 1] == ':') - { - var portString = address.Substring(closingBracketIndex + 2); - if (int.TryParse(portString, out var port)) - { - return port; - } - } - } - return -1; + var closeBracket = address.IndexOf(']'); + if (closeBracket < 0) + return -1; + + colonIndex = closeBracket + 1 < address.Length && address[closeBracket + 1] == ':' + ? closeBracket + 1 + : -1; + } + else + { + colonIndex = address.LastIndexOf(':'); } - // For non-bracketed addresses, use the last colon to split host and port - var colonIndex = address.LastIndexOf(':'); if (colonIndex >= 0 && colonIndex < address.Length - 1) { if (int.TryParse(address.Substring(colonIndex + 1), out var port)) diff --git a/src/net-questdb-client/Utils/LineSenderServerException.cs b/src/net-questdb-client/Utils/LineSenderServerException.cs new file mode 100644 index 00000000..f559d222 --- /dev/null +++ b/src/net-questdb-client/Utils/LineSenderServerException.cs @@ -0,0 +1,60 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; + +namespace QuestDB.Utils; + +/// +/// Thrown from the producer thread when the SF cursor engine has latched a +/// -policy . +/// The structured payload is on . +/// +public sealed class LineSenderServerException : IngressError +{ + /// Wraps a HALT-policy for rethrow on the producer thread. + public LineSenderServerException(SenderError error) + : base(MapErrorCode(error.Category), Format(error)) + { + Error = error; + } + + /// The structured error payload from the server (or engine). + public SenderError Error { get; } + + private static string Format(SenderError e) + { + var msg = string.IsNullOrEmpty(e.ServerMessage) ? "(no message)" : e.ServerMessage; + return $"server rejected batch [category={e.Category}, status=0x{e.ServerStatusByte & 0xFF:X2}, " + + $"fsn=[{e.FromFsn},{e.ToFsn}], table={e.TableName ?? "(none)"}]: {msg}"; + } + + private static ErrorCode MapErrorCode(SenderErrorCategory category) => + category switch + { + SenderErrorCategory.SecurityError => ErrorCode.AuthError, + SenderErrorCategory.ParseError => ErrorCode.InvalidApiCall, + _ => ErrorCode.ServerFlushError, + }; +} diff --git a/src/net-questdb-client/Utils/QwpTlsAuth.cs b/src/net-questdb-client/Utils/QwpTlsAuth.cs new file mode 100644 index 00000000..5c708292 --- /dev/null +++ b/src/net-questdb-client/Utils/QwpTlsAuth.cs @@ -0,0 +1,141 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using QuestDB.Enums; + +namespace QuestDB.Utils; + +/// +/// Shared auth/TLS helpers used by both the ingress sender and the egress query client. +/// +internal static class QwpTlsAuth +{ + /// + /// Builds the value of the Authorization upgrade header. At most one of + /// username + password or may be supplied; + /// mutual-exclusion is the caller's responsibility (validated by + /// / QueryOptions). + /// + /// The header value, or null if no auth is configured. + public static string? BuildAuthHeader(string? username, string? password, string? token) + { + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) + { + var pair = $"{username}:{password}"; + return "Basic " + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(pair)); + } + + if (!string.IsNullOrEmpty(token)) + { + return "Bearer " + token; + } + + return null; + } + + /// + /// Owns the for the TLS handshake and, + /// for custom-root pinning, the loaded CA certificate. The owning sender / query client + /// must this so the native cert handle is freed deterministically + /// instead of waiting on GC finalization. + /// + internal sealed class CertificateValidator : IDisposable + { + private readonly Lazy? _trustRoot; + + internal CertificateValidator(RemoteCertificateValidationCallback callback, Lazy? trustRoot) + { + Callback = callback; + _trustRoot = trustRoot; + } + + public RemoteCertificateValidationCallback Callback { get; } + + public void Dispose() + { + if (_trustRoot is { IsValueCreated: true }) + { + _trustRoot.Value.Dispose(); + } + } + } + + /// + /// Builds the certificate validator for the TLS handshake. Returns null when the + /// system default chain validation is sufficient. The returned holder owns any loaded + /// custom-root certificate and must be disposed by the caller. + /// + public static CertificateValidator? BuildCertificateValidator( + TlsVerifyType tlsVerify, + string? tlsRoots, + string? tlsRootsPassword) + { + if (tlsVerify == TlsVerifyType.unsafe_off) + { + return new CertificateValidator((_, _, _, _) => true, trustRoot: null); + } + + if (string.IsNullOrEmpty(tlsRoots)) + { + return null; + } + + // Lazy-load on first handshake so a non-existent path doesn't fail at builder time; + // once loaded the cert is cached and every subsequent handshake reuses it. + var trustRoot = new Lazy(() => LoadTrustRoot(tlsRoots, tlsRootsPassword)); + RemoteCertificateValidationCallback callback = (_, certificate, chain, errors) => + { + if ((errors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0) + { + return false; + } + + using var serverCert = new X509Certificate2(certificate!); + chain!.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + if (chain.ChainPolicy.CustomTrustStore.Count == 0) + { + chain.ChainPolicy.CustomTrustStore.Add(trustRoot.Value); + } + return chain.Build(serverCert); + }; + return new CertificateValidator(callback, trustRoot); + } + + internal static X509Certificate2 LoadTrustRoot(string path, string? password) + { + // CreateFromPemFile's second arg is a key file path, not a password — leave it null for PEM. + var ext = System.IO.Path.GetExtension(path); + if (string.Equals(ext, ".pfx", StringComparison.OrdinalIgnoreCase) + || string.Equals(ext, ".p12", StringComparison.OrdinalIgnoreCase)) + { +#pragma warning disable SYSLIB0057 + return new X509Certificate2(path, password); +#pragma warning restore SYSLIB0057 + } + return X509Certificate2.CreateFromPemFile(path); + } +} diff --git a/src/net-questdb-client/Utils/SenderError.cs b/src/net-questdb-client/Utils/SenderError.cs new file mode 100644 index 00000000..1fdd9d8c --- /dev/null +++ b/src/net-questdb-client/Utils/SenderError.cs @@ -0,0 +1,147 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using QuestDB.Enums; + +namespace QuestDB.Utils; + +/// +/// Immutable description of a server-side rejection or terminal engine failure. +/// Delivered both asynchronously to and +/// synchronously to the producer thread (HALT-policy errors are wrapped in +/// on the next API call). +/// The [FromFsn, ToFsn] span is the load-bearing correlation key. +/// +public sealed class SenderError +{ + /// Sentinel for when the wire layer carries no QWP frame sequence. + public const long NoMessageSequence = -1L; + + /// Sentinel for on . + public const int NoStatusByte = -1; + + /// Creates an immutable error record. Constructed by the engine, not by user code. + public SenderError( + SenderErrorCategory category, + SenderErrorPolicy appliedPolicy, + int serverStatusByte, + string? serverMessage, + long messageSequence, + long fromFsn, + long toFsn, + string? tableName, + DateTime detectedAtUtc, + Exception? exception = null, + bool isInitialConnect = false) + { + Category = category; + AppliedPolicy = appliedPolicy; + ServerStatusByte = serverStatusByte; + ServerMessage = serverMessage; + MessageSequence = messageSequence; + FromFsn = fromFsn; + ToFsn = toFsn; + TableName = tableName; + DetectedAtUtc = detectedAtUtc; + Exception = exception; + IsInitialConnect = isInitialConnect; + } + + /// The rejection category. + public SenderErrorCategory Category { get; } + + /// + /// The policy the I/O loop actually applied. + /// means the data was dropped; means a + /// will be thrown on the next producer-thread call. + /// + public SenderErrorPolicy AppliedPolicy { get; } + + /// + /// Raw status byte from the server (e.g. 0x03 for SchemaMismatch), or + /// on + /// and engine-internal terminal failures. + /// + public int ServerStatusByte { get; } + + /// Server-supplied human-readable message (≤1024 UTF-8 bytes), or null. + public string? ServerMessage { get; } + + /// + /// Server's per-frame messageSequence as mirrored back in the rejection frame, or + /// for engine-internal failures. + /// + public long MessageSequence { get; } + + /// Inclusive lower bound of the FSN span for the rejected batch. + public long FromFsn { get; } + + /// Inclusive upper bound of the FSN span for the rejected batch. + public long ToFsn { get; } + + /// + /// Rejected table name when the server attributed the error to a single table; + /// null when the rejected batch carried rows for multiple tables, or no attribution. + /// + public string? TableName { get; } + + /// Wall-clock receipt time on the I/O thread. + public DateTime DetectedAtUtc { get; } + + /// + /// The terminal exception latched by the engine for non-server failures + /// (connect-budget exhaustion, fatal upgrade reject). Null for server-side rejections. + /// + public Exception? Exception { get; } + + /// + /// true if the engine never reached a successful first connection (config / + /// connectivity issue); false if it had connected at least once before failing. + /// Always false for server-side rejections. + /// + public bool IsInitialConnect { get; } + + /// + public override string ToString() + { + return $"SenderError{{category={Category}, policy={AppliedPolicy}, " + + $"status=0x{ServerStatusByte & 0xFF:X2}, seq={MessageSequence}, " + + $"fsn=[{FromFsn},{ToFsn}], table={TableName ?? "(none)"}, msg={ServerMessage}}}"; + } +} + +/// +/// Callback for . Invoked on a background +/// dispatcher; thrown exceptions are caught and traced. +/// +public delegate void SenderErrorHandler(SenderError error); + +/// +/// Callback for . Returns the +/// to apply for a given . +/// and +/// are forced +/// regardless of what the resolver returns. +/// +public delegate SenderErrorPolicy SenderErrorPolicyResolver(SenderErrorCategory category); diff --git a/src/net-questdb-client/Utils/SenderOptions.cs b/src/net-questdb-client/Utils/SenderOptions.cs index af11799c..5dbd5493 100644 --- a/src/net-questdb-client/Utils/SenderOptions.cs +++ b/src/net-questdb-client/Utils/SenderOptions.cs @@ -29,8 +29,11 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json.Serialization; using QuestDB.Enums; +using QuestDB.Qwp; +using QuestDB.Qwp.Sf; using QuestDB.Senders; // ReSharper disable InconsistentNaming @@ -48,23 +51,19 @@ public record SenderOptions /// public const int ARRAY_MAX_DIMENSIONS = 32; - private static readonly HashSet keySet = new() - { - "protocol", "protocol_version", "addr", "auto_flush", "auto_flush_rows", "auto_flush_bytes", - "auto_flush_interval", "init_buf_size", "max_buf_size", "max_name_len", "username", "password", "token", - "request_min_throughput", "auth_timeout", "request_timeout", "retry_timeout", - "pool_timeout", "tls_verify", "tls_roots", "tls_roots_password", "own_socket", "gzip", - }; - private string _addr = "localhost:9000"; private List _addresses = new(); private TimeSpan _authTimeout = TimeSpan.FromMilliseconds(15000); private AutoFlushType _autoFlush = AutoFlushType.on; private int _autoFlushBytes = int.MaxValue; + private bool _autoFlushBytesUserSet; private TimeSpan _autoFlushInterval = TimeSpan.FromMilliseconds(1000); + private bool _autoFlushIntervalUserSet; private int _autoFlushRows = 75000; - private DbConnectionStringBuilder _connectionStringBuilder = null!; - private bool _gzip = false; + private bool _autoFlushRowsUserSet; + private DbConnectionStringBuilder? _connectionStringBuilder; + private bool _convertLocalToUtc; + private bool _gzip; private int _initBufSize = 65536; private int _maxBufSize = 104857600; private int _maxNameLen = 127; @@ -74,18 +73,84 @@ public record SenderOptions private ProtocolType _protocol = ProtocolType.http; private ProtocolVersion _protocol_version = ProtocolVersion.Auto; private int _requestMinThroughput = 102400; - private TimeSpan _requestTimeout = TimeSpan.FromMilliseconds(10000); + private TimeSpan _requestTimeout = TimeSpan.FromMilliseconds(30000); private TimeSpan _retryTimeout = TimeSpan.FromMilliseconds(10000); - private string? _tlsCa; private string? _tlsRoots; private string? _tlsRootsPassword; private TlsVerifyType _tlsVerify = TlsVerifyType.on; private string? _token; - private string? _tokenX; - private string? _tokenY; private string? _username; private X509Certificate2? _clientCert; + // WebSocket / QWP knobs. + private bool _requestDurableAck; + private bool _transaction; + + private string? _sfDir; + private string _senderId = "default"; + private long _sfMaxBytes = 4L * 1024 * 1024; + private long _sfMaxTotalBytes = 128L * 1024 * 1024; + private string _sfDurability = "memory"; + private TimeSpan _sfAppendDeadline = TimeSpan.FromMilliseconds(30000); + + // The reconnect loop sleeps via Task.Delay, which throws once the delay exceeds its supported + // range (int.MaxValue ms on the oldest target runtime, ~24.8 days). Every reconnect sleep is + // clipped to the remaining outage budget, so bounding these three knobs at that ceiling keeps any + // computed backoff in range and turns a multi-week misconfig into a clear config error. + private const long MaxReconnectMillis = int.MaxValue; + private TimeSpan _reconnectMaxDuration = TimeSpan.FromMilliseconds(300000); + private TimeSpan _reconnectInitialBackoff = TimeSpan.FromMilliseconds(100); + private TimeSpan _reconnectMaxBackoff = TimeSpan.FromMilliseconds(5000); + private InitialConnectMode _initialConnectMode = InitialConnectMode.off; + private TimeSpan _closeFlushTimeout = TimeSpan.FromMilliseconds(60000); + private bool _drainOrphans; + private int _maxBackgroundDrainers = 4; + private TimeSpan _pingTimeout = TimeSpan.FromMilliseconds(5000); + private TimeSpan _durableAckKeepaliveInterval = TimeSpan.FromMilliseconds(200); + private string? _proxy; + private string? _zone; + private SenderErrorHandler? _errorHandler; + private SenderErrorPolicyResolver? _errorPolicyResolver; + private int _errorInboxCapacity = 256; + private int _connectionListenerInboxCapacity = 256; + private QuestDB.Senders.ISenderConnectionListener? _connectionListener; + private SenderErrorPolicy? _onServerError; + private SenderErrorPolicy? _onSchemaMismatchError; + private SenderErrorPolicy? _onParseError; + private SenderErrorPolicy? _onInternalError; + private SenderErrorPolicy? _onSecurityError; + private SenderErrorPolicy? _onWriteError; + + private bool _requestDurableAckUserSet; + private bool _transactionUserSet; + private bool _sfDirUserSet; + private bool _senderIdUserSet; + private bool _sfMaxBytesUserSet; + private bool _sfMaxTotalBytesUserSet; + private bool _sfDurabilityUserSet; + private bool _sfAppendDeadlineUserSet; + private bool _reconnectMaxDurationUserSet; + private bool _reconnectInitialBackoffUserSet; + private bool _reconnectMaxBackoffUserSet; + private bool _initialConnectModeUserSet; + private bool _closeFlushTimeoutUserSet; + private bool _drainOrphansUserSet; + private bool _maxBackgroundDrainersUserSet; + private bool _pingTimeoutUserSet; + private bool _durableAckKeepaliveIntervalUserSet; + private bool _proxyUserSet; + private bool _errorHandlerUserSet; + private bool _errorPolicyResolverUserSet; + private bool _errorInboxCapacityUserSet; + private bool _connectionListenerInboxCapacityUserSet; + private bool _connectionListenerUserSet; + private bool _onServerErrorUserSet; + private bool _onSchemaMismatchErrorUserSet; + private bool _onParseErrorUserSet; + private bool _onInternalErrorUserSet; + private bool _onSecurityErrorUserSet; + private bool _onWriteErrorUserSet; + /// /// Construct a object with default values. /// @@ -106,25 +171,637 @@ public SenderOptions(string confStr) ParseStringWithDefault(nameof(addr), "localhost:9000", out _addr!); ParseAddresses(); ParseEnumWithDefault(nameof(auto_flush), "on", out _autoFlush); - ParseIntThatMayBeOff(nameof(auto_flush_rows), IsHttp() ? "75000" : "600", out _autoFlushRows); - ParseIntThatMayBeOff(nameof(auto_flush_bytes), int.MaxValue.ToString(), out _autoFlushBytes); - ParseMillisecondsThatMayBeOff(nameof(auto_flush_interval), "1000", out _autoFlushInterval); + var isWs = IsWebSocket(); + var defaultAutoFlushRows = isWs ? "1000" : "75000"; + var defaultAutoFlushBytes = isWs + ? (8 * 1024 * 1024).ToString(System.Globalization.CultureInfo.InvariantCulture) + : int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture); + var defaultAutoFlushIntervalMs = isWs ? "100" : "1000"; + // Explicitness stops ApplyAutoFlushNormalisation overwriting a ws:: value equal to an HTTP default. + _autoFlushRowsUserSet = ReadOptionFromBuilder(nameof(auto_flush_rows)) is not null; + _autoFlushBytesUserSet = ReadOptionFromBuilder(nameof(auto_flush_bytes)) is not null; + _autoFlushIntervalUserSet = ReadOptionFromBuilder(nameof(auto_flush_interval)) is not null; + ParseIntThatMayBeOff(nameof(auto_flush_rows), defaultAutoFlushRows, out _autoFlushRows, + rejectLiteralZero: true); + ParseIntThatMayBeOff(nameof(auto_flush_bytes), defaultAutoFlushBytes, out _autoFlushBytes); + ParseMillisecondsThatMayBeOff(nameof(auto_flush_interval), defaultAutoFlushIntervalMs, + out _autoFlushInterval, rejectLiteralZero: true); ParseBoolWithDefault(nameof(gzip), "false", out _gzip); ParseIntWithDefault(nameof(init_buf_size), "65536", out _initBufSize); ParseIntWithDefault(nameof(max_buf_size), "104857600", out _maxBufSize); ParseIntWithDefault(nameof(max_name_len), "127", out _maxNameLen); ParseStringWithDefault(nameof(username), null, out _username); + if (_username is null) + { + ParseStringWithDefault("user", null, out _username); + } + ParseStringWithDefault(nameof(password), null, out _password); + if (_password is null) + { + ParseStringWithDefault("pass", null, out _password); + } + ParseStringWithDefault(nameof(token), null, out _token); ParseIntWithDefault(nameof(request_min_throughput), "102400", out _requestMinThroughput); ParseMillisecondsWithDefault(nameof(auth_timeout), "15000", out _authTimeout); - ParseMillisecondsWithDefault(nameof(request_timeout), "10000", out _requestTimeout); + if (ReadOptionFromBuilder("auth_timeout_ms") is not null) + { + if (!IsWebSocket()) + { + throw new IngressError(ErrorCode.ConfigError, + "`auth_timeout_ms` is only supported for WebSocket transport"); + } + ParseMillisecondsWithDefault("auth_timeout_ms", "15000", out _authTimeout); + } + if (IsWebSocket()) + { + foreach (var key in IlpHttpOnlyKeys) + { + if (ReadOptionFromBuilder(key) is not null) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{key}` is not supported for QWP/WebSocket transport"); + } + } + } + ParseMillisecondsWithDefault(nameof(request_timeout), "30000", out _requestTimeout); ParseMillisecondsWithDefault(nameof(retry_timeout), "10000", out _retryTimeout); ParseMillisecondsWithDefault(nameof(pool_timeout), "120000", out _poolTimeout); ParseEnumWithDefault(nameof(tls_verify), "on", out _tlsVerify); ParseStringWithDefault(nameof(tls_roots), null, out _tlsRoots); ParseStringWithDefault(nameof(tls_roots_password), null, out _tlsRootsPassword); ParseBoolWithDefault(nameof(own_socket), "true", out _ownSocket); + ParseBoolOnOff(nameof(convert_local_to_utc), "off", out _convertLocalToUtc); + + // WebSocket / QWP knobs. Parsed unconditionally; ValidateWebSocketKeys throws if any + // appear with a non-WebSocket scheme. + ParseBoolOnOff(nameof(request_durable_ack), "off", out _requestDurableAck); + ParseBoolOnOff(nameof(transaction), "off", out _transaction); + + ParseStringWithDefault(nameof(sf_dir), null, out _sfDir); + ParseStringWithDefault(nameof(sender_id), "default", out var senderIdRaw); + SetSenderId(senderIdRaw ?? "default"); + ParseLongWithDefault(nameof(sf_max_bytes), + (4L * 1024 * 1024).ToString(System.Globalization.CultureInfo.InvariantCulture), out _sfMaxBytes); + _sfMaxTotalBytesUserSet = ReadOptionFromBuilder(nameof(sf_max_total_bytes)) is not null; + var defaultMaxTotal = string.IsNullOrEmpty(_sfDir) + ? 128L * 1024 * 1024 + : 10L * 1024 * 1024 * 1024; + ParseLongWithDefault(nameof(sf_max_total_bytes), + defaultMaxTotal.ToString(System.Globalization.CultureInfo.InvariantCulture), out _sfMaxTotalBytes); + ParseStringWithDefault(nameof(sf_durability), "memory", out var sfDurabilityRaw); + _sfDurability = sfDurabilityRaw ?? "memory"; + if (!_sfDurability.Equals("memory", StringComparison.OrdinalIgnoreCase)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_durability` only accepts 'memory' in v1, got `{_sfDurability}`"); + } + + ParseMillisecondsWithDefault(nameof(sf_append_deadline_millis), "30000", out _sfAppendDeadline); + ParseMillisecondsWithDefault(nameof(reconnect_max_duration_millis), "300000", out _reconnectMaxDuration); + ParseMillisecondsWithDefault(nameof(reconnect_initial_backoff_millis), "100", out _reconnectInitialBackoff); + ParseMillisecondsWithDefault(nameof(reconnect_max_backoff_millis), "5000", out _reconnectMaxBackoff); + _initialConnectMode = ParseInitialConnectMode( + ReadOptionFromBuilder(nameof(initial_connect_retry))); + ParseMillisecondsWithDefault(nameof(close_flush_timeout_millis), "60000", out _closeFlushTimeout); + ParseBoolOnOff(nameof(drain_orphans), "off", out _drainOrphans); + ParseIntWithDefault(nameof(max_background_drainers), "4", out _maxBackgroundDrainers); + ParseMillisecondsWithDefault(nameof(ping_timeout), "5000", out _pingTimeout); + ParseMillisecondsWithDefault(nameof(durable_ack_keepalive_interval_millis), "200", + out _durableAckKeepaliveInterval); + ParseStringWithDefault(nameof(proxy), null, out _proxy); + ParseStringWithDefault(nameof(zone), null, out _zone); + + ParseIntWithDefault(nameof(error_inbox_capacity), "256", out _errorInboxCapacity); + ParseIntWithDefault(nameof(connection_listener_inbox_capacity), "256", out _connectionListenerInboxCapacity); + + _onServerError = ParsePolicyKey(nameof(on_server_error)); + _onSchemaMismatchError = ParsePolicyKey(nameof(on_schema_mismatch_error), aliasName: "on_schema_error"); + _onParseError = ParsePolicyKey(nameof(on_parse_error)); + _onInternalError = ParsePolicyKey(nameof(on_internal_error)); + _onSecurityError = ParsePolicyKey(nameof(on_security_error)); + _onWriteError = ParsePolicyKey(nameof(on_write_error)); + + RejectUnknownConnectStringKeys(); + EnsureValid(); + } + + /// + /// Legacy ILP HTTP/TCP keys that have no meaning on the QWP/WebSocket transport and are + /// absent from the QWP connect-string vocabulary (connect-string.md Key index). They are + /// accepted on http / https / tcp / tcps but rejected when any + /// of them appears in a ws:: / wss:: connect string, so a string shared with + /// the QWP query client cannot smuggle an ILP-only knob onto the QWP path. Declared before + /// because that initializer folds this array in. + /// + private static readonly string[] IlpHttpOnlyKeys = + { + "protocol_version", + "request_min_throughput", + "request_timeout", + "retry_timeout", + "gzip", + "pool_timeout", + "own_socket", + "token_x", + "token_y", + "auth_timeout", + }; + + /// + /// Boolean keys whose documented vocabulary is on / off (parsed via + /// ). emits these as on/off + /// rather than the CLR True/False literal so the output speaks the same tokens + /// the parser documents. Keep in sync with the call sites. + /// + private static readonly string[] OnOffBoolKeys = + { + nameof(convert_local_to_utc), + nameof(request_durable_ack), + nameof(transaction), + nameof(drain_orphans), + }; + + private static readonly HashSet KnownConnectStringKeys = BuildKnownConnectStringKeys(); + + private static HashSet BuildKnownConnectStringKeys() + { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var k in QwpConnectStringKeys.Shared) keys.Add(k); + foreach (var k in QwpConnectStringKeys.IngressOnly) keys.Add(k); + // The connect string is one shared input; accept (and ignore) the egress query client's keys. + foreach (var k in QwpConnectStringKeys.EgressOnly) keys.Add(k); + // Legacy ILP HTTP/TCP keys are not QWP vocabulary, so they live outside QwpConnectStringKeys. + // The ILP transports still parse them; the WebSocket path rejects them via IlpHttpOnlyKeys. + foreach (var k in IlpHttpOnlyKeys) keys.Add(k); + return keys; + } + + private void RejectUnknownConnectStringKeys() + { + if (_connectionStringBuilder is null) return; + foreach (string key in _connectionStringBuilder.Keys) + { + if (!KnownConnectStringKeys.Contains(key)) + { + throw new IngressError(ErrorCode.ConfigError, + $"Unknown connect-string key `{key}`"); + } + } + } + + private void ValidateAuthCombination() + { + RejectControlChars(nameof(username), _username); + RejectControlChars(nameof(password), _password); + RejectControlChars(nameof(token), _token); + + var hasUsername = !string.IsNullOrEmpty(_username); + var hasPassword = !string.IsNullOrEmpty(_password); + var hasToken = !string.IsNullOrEmpty(_token); + + if (IsTcp()) + { + if (hasPassword) + { + throw new IngressError(ErrorCode.ConfigError, + "`password` is not used by the TCP transport; use `username`+`token` for ECDSA auth"); + } + + if (hasUsername != hasToken) + { + throw new IngressError(ErrorCode.ConfigError, + "TCP ECDSA auth requires both `username` (kid) and `token` (secret)"); + } + + return; + } + + if (hasUsername && hasToken) + { + throw new IngressError(ErrorCode.ConfigError, + "`username` and `token` are mutually exclusive: pick Basic or Bearer auth, not both"); + } + + if (hasUsername && !hasPassword) + { + throw new IngressError(ErrorCode.ConfigError, + "`username` requires `password` for Basic auth"); + } + + if (hasPassword && !hasUsername) + { + throw new IngressError(ErrorCode.ConfigError, + "`password` requires `username` for Basic auth"); + } + } + + private void ValidateTlsCombination() + { + var hasRoots = !string.IsNullOrEmpty(_tlsRoots); + var hasRootsPassword = !string.IsNullOrEmpty(_tlsRootsPassword); + + if (hasRootsPassword && !hasRoots) + { + throw new IngressError(ErrorCode.ConfigError, + "`tls_roots_password` requires `tls_roots`"); + } + } + + private void ValidateGzipForWebSocket() + { + if (IsWebSocket() && _gzip) + { + throw new IngressError(ErrorCode.ConfigError, + "`gzip=on` is not supported with the ws:: or wss:: scheme"); + } + } + + private void ValidateAutoFlushBytesForWebSocket() + { + if (!IsWebSocket()) return; + if (_autoFlushBytes <= 0 || _autoFlushBytes == int.MaxValue) return; + const int wsMaxAutoFlushBytes = Qwp.QwpConstants.MaxBatchBytes / 2; + if (_autoFlushBytes > wsMaxAutoFlushBytes) + { + throw new IngressError(ErrorCode.ConfigError, + $"`auto_flush_bytes` for ws/wss must be ≤ {wsMaxAutoFlushBytes} (half of MaxBatchBytes); got {_autoFlushBytes}"); + } + } + + private void ParseBoolOnOff(string name, string defaultValue, out bool field) + { + var raw = ReadOptionFromBuilder(name) ?? defaultValue; + if (!TryParseInteropBool(raw, out field)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{name}` must be 'on' or 'off' (or 'true'/'false'), got `{raw}`"); + } + } + + private SenderErrorPolicy? ParsePolicyKey(string name, string? aliasName = null) + { + var raw = ReadOptionFromBuilder(name); + if (aliasName is not null) + { + var aliasRaw = ReadOptionFromBuilder(aliasName); + if (raw is not null && aliasRaw is not null) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{name}` and `{aliasName}` are aliases; set only one"); + } + raw ??= aliasRaw; + } + if (raw is null) return null; + if (string.Equals(raw, "halt", StringComparison.OrdinalIgnoreCase)) + { + return SenderErrorPolicy.Halt; + } + if (string.Equals(raw, "drop", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "drop_and_continue", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, nameof(SenderErrorPolicy.DropAndContinue), StringComparison.OrdinalIgnoreCase)) + { + return SenderErrorPolicy.DropAndContinue; + } + throw new IngressError(ErrorCode.ConfigError, + $"`{name}` must be one of [halt, drop, drop_and_continue], got `{raw}`"); + } + + private static InitialConnectMode ParseInitialConnectMode(string? raw) + { + if (string.IsNullOrEmpty(raw)) return InitialConnectMode.off; + if (string.Equals(raw, "off", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase)) + { + return InitialConnectMode.off; + } + if (string.Equals(raw, "on", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "sync", StringComparison.OrdinalIgnoreCase)) + { + return InitialConnectMode.on; + } + if (string.Equals(raw, "async", StringComparison.OrdinalIgnoreCase)) + { + return InitialConnectMode.async; + } + throw new IngressError(ErrorCode.ConfigError, + $"`initial_connect_retry` must be one of [off, on, async] (also accepts [false, true, sync]), got `{raw}`"); + } + + private bool IsKeyExplicit(string name) + { + return _connectionStringBuilder?.ContainsKey(name) ?? false; + } + + private void ValidateWebSocketKeys() + { + if (IsWebSocket() || _connectionStringBuilder is null) + { + return; + } + + foreach (var wsOnlyKey in WebSocketOnlyKeys) + { + if (IsKeyExplicit(wsOnlyKey)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{wsOnlyKey}` is only supported with the ws:: or wss:: scheme"); + } + } + } + + private void ValidateTimeouts() + { + if (_authTimeout <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`auth_timeout` must be > 0; got {_authTimeout.TotalMilliseconds}ms"); + if (_requestTimeout <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`request_timeout` must be > 0; got {_requestTimeout.TotalMilliseconds}ms"); + if (_retryTimeout < TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`retry_timeout` must be ≥ 0; got {_retryTimeout.TotalMilliseconds}ms"); + if (_poolTimeout <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`pool_timeout` must be > 0; got {_poolTimeout.TotalMilliseconds}ms"); + if (IsWebSocket()) + { + // 0 is the deliberate "fast close, don't wait" opt-out; a negative would fold into the + // same `<= 0` guard at dispose and silently drop buffered rows in RAM mode. + if (_closeFlushTimeout < TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`close_flush_timeout_millis` must be ≥ 0; got {_closeFlushTimeout.TotalMilliseconds}ms"); + if (_sfAppendDeadline <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`sf_append_deadline_millis` must be > 0; got {_sfAppendDeadline.TotalMilliseconds}ms"); + if (_reconnectMaxDuration <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_max_duration_millis` must be > 0; got {_reconnectMaxDuration.TotalMilliseconds}ms"); + if (_reconnectMaxDuration.TotalMilliseconds > MaxReconnectMillis) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_max_duration_millis` must be ≤ {MaxReconnectMillis}ms (~24.8 days); got {_reconnectMaxDuration.TotalMilliseconds}ms"); + if (_reconnectInitialBackoff <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_initial_backoff_millis` must be > 0; got {_reconnectInitialBackoff.TotalMilliseconds}ms"); + if (_reconnectInitialBackoff.TotalMilliseconds > MaxReconnectMillis) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_initial_backoff_millis` must be ≤ {MaxReconnectMillis}ms (~24.8 days); got {_reconnectInitialBackoff.TotalMilliseconds}ms"); + if (_reconnectMaxBackoff <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_max_backoff_millis` must be > 0; got {_reconnectMaxBackoff.TotalMilliseconds}ms"); + if (_reconnectMaxBackoff.TotalMilliseconds > MaxReconnectMillis) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_max_backoff_millis` must be ≤ {MaxReconnectMillis}ms (~24.8 days); got {_reconnectMaxBackoff.TotalMilliseconds}ms"); + if (_reconnectInitialBackoff > _reconnectMaxBackoff) + throw new IngressError(ErrorCode.ConfigError, + $"`reconnect_initial_backoff_millis` ({_reconnectInitialBackoff.TotalMilliseconds}ms) must be ≤ `reconnect_max_backoff_millis` ({_reconnectMaxBackoff.TotalMilliseconds}ms)"); + if (_pingTimeout <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`ping_timeout` must be > 0; got {_pingTimeout.TotalMilliseconds}ms"); + } + } + + internal void EnsureValid() + { + ValidateAuthCombination(); + ValidateTlsCombination(); + ValidateMultiAddressForTcp(); + ValidateStoreAndForwardOptions(); + ValidateGzipForWebSocket(); + ApplyAutoFlushNormalisation(); + ValidateAutoFlushBytesForWebSocket(); + ValidateTimeouts(); + ValidateWebSocketKeys(); + ValidateWebSocketKeysAgainstDefaults(); + ApplyInitialConnectPromotion(); + ValidateErrorInboxCapacity(); + ValidateBufferSizes(); + } + + /// + /// An explicit initial_connect_retry choice always wins. When the user left it unset + /// but tuned any reconnect_* knob, promote to so + /// the reconnect budget also covers the first connect — otherwise the budget would silently + /// apply only to post-disconnect reconnects. + /// + private void ApplyInitialConnectPromotion() + { + if (_initialConnectMode != InitialConnectMode.off) return; + + var fromString = _connectionStringBuilder is not null; + if (fromString && IsKeyExplicit(nameof(initial_connect_retry))) return; + if (!fromString && _initialConnectModeUserSet) return; + + var reconnectTuned = fromString + ? IsKeyExplicit(nameof(reconnect_max_duration_millis)) + || IsKeyExplicit(nameof(reconnect_initial_backoff_millis)) + || IsKeyExplicit(nameof(reconnect_max_backoff_millis)) + : _reconnectMaxDurationUserSet || _reconnectInitialBackoffUserSet || _reconnectMaxBackoffUserSet; + + if (reconnectTuned) + { + _initialConnectMode = InitialConnectMode.on; + } + } + + private void ValidateBufferSizes() + { + if (_maxNameLen <= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`max_name_len` must be > 0; got {_maxNameLen}"); + } + if (_initBufSize <= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`init_buf_size` must be > 0; got {_initBufSize}"); + } + if (_maxBufSize <= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`max_buf_size` must be > 0; got {_maxBufSize}"); + } + if (_maxBufSize < _initBufSize) + { + throw new IngressError(ErrorCode.ConfigError, + $"`max_buf_size` ({_maxBufSize}) must be >= `init_buf_size` ({_initBufSize})"); + } + if (IsWebSocket()) + { + if (_maxBackgroundDrainers < 1) + { + throw new IngressError(ErrorCode.ConfigError, + $"`max_background_drainers` must be >= 1; got {_maxBackgroundDrainers}"); + } + } + } + + private void ValidateErrorInboxCapacity() + { + if (_errorInboxCapacity < 16) + { + throw new IngressError(ErrorCode.ConfigError, + $"`error_inbox_capacity` must be >= 16; got {_errorInboxCapacity}"); + } + if (_connectionListenerInboxCapacity < 1) + { + throw new IngressError(ErrorCode.ConfigError, + $"`connection_listener_inbox_capacity` must be >= 1; got {_connectionListenerInboxCapacity}"); + } + } + + private bool HasAnyPolicyKeySet() => + _onServerError.HasValue || _onSchemaMismatchError.HasValue || _onParseError.HasValue + || _onInternalError.HasValue || _onSecurityError.HasValue || _onWriteError.HasValue; + + /// + /// Build the effective resolver per the precedence chain: + /// → per-category override + /// ( etc.) → → + /// spec defaults. Returns null when no override is configured (engine then falls through + /// to spec defaults). and + /// are forced halt by the engine, so a + /// resolver returning anything else for them is ignored. + /// + internal SenderErrorPolicyResolver? BuildEffectivePolicyResolver() + { + if (_errorPolicyResolver != null) return _errorPolicyResolver; + if (!HasAnyPolicyKeySet()) return null; + var schema = _onSchemaMismatchError ?? _onServerError; + var parse = _onParseError ?? _onServerError; + var internalErr = _onInternalError ?? _onServerError; + var security = _onSecurityError ?? _onServerError; + var write = _onWriteError ?? _onServerError; + return category => category switch + { + SenderErrorCategory.SchemaMismatch => schema ?? QwpErrorClassifier.DefaultPolicy(category), + SenderErrorCategory.ParseError => parse ?? QwpErrorClassifier.DefaultPolicy(category), + SenderErrorCategory.InternalError => internalErr ?? QwpErrorClassifier.DefaultPolicy(category), + SenderErrorCategory.SecurityError => security ?? QwpErrorClassifier.DefaultPolicy(category), + SenderErrorCategory.WriteError => write ?? QwpErrorClassifier.DefaultPolicy(category), + _ => QwpErrorClassifier.DefaultPolicy(category), + }; + } + + private void ValidateMultiAddressForTcp() + { + if ((protocol == ProtocolType.tcp || protocol == ProtocolType.tcps) && _addresses.Count > 1) + { + throw new IngressError(ErrorCode.ConfigError, + "Multiple `addr=` entries are not supported on tcp/tcps; use http/https or ws/wss for multi-host failover."); + } + } + + private void ValidateStoreAndForwardOptions() + { + if (!_sfDurability.Equals("memory", StringComparison.OrdinalIgnoreCase)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_durability` only accepts 'memory' in v1, got `{_sfDurability}`"); + } + + // Programmatic init's field initializer is the no-SF default (128 MiB); promote when sf_dir + // is set and the user didn't pick their own value. Equality-on-128MiB would falsely promote + // an explicit user 128 MiB. + if (!_sfMaxTotalBytesUserSet && !string.IsNullOrEmpty(_sfDir)) + { + _sfMaxTotalBytes = 10L * 1024 * 1024 * 1024; + } + + if (_sfMaxBytes <= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_max_bytes` must be > 0; got {_sfMaxBytes}"); + } + // A segment is buffered as a single contiguous byte[] (QwpCursorSendEngine's send buffer), + // so it must fit a managed array; reject early as a clean ConfigError instead of letting an + // oversized value surface as an opaque OverflowException/OOM at engine construction. + if (_sfMaxBytes > Array.MaxLength) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_max_bytes` ({_sfMaxBytes}) exceeds the maximum single-segment size of {Array.MaxLength} bytes"); + } + if (_sfMaxTotalBytes <= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_max_total_bytes` must be > 0; got {_sfMaxTotalBytes}"); + } + // Overflow-safe form of `_sfMaxTotalBytes < 2 * _sfMaxBytes`; the doubled multiply + // wraps negative for huge sf_max_bytes and would defeat the check. + if (_sfMaxTotalBytes / 2 < _sfMaxBytes) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_max_total_bytes` ({_sfMaxTotalBytes}) must be >= 2 * `sf_max_bytes` ({_sfMaxBytes}) so the segment manager has room to provision a hot spare."); + } + } + + private void ValidateWebSocketKeysAgainstDefaults() + { + if (IsWebSocket()) + { + return; + } + + if (_requestDurableAckUserSet) Throw(nameof(request_durable_ack)); + if (_transactionUserSet) Throw(nameof(transaction)); + if (_sfDirUserSet) Throw(nameof(sf_dir)); + if (_senderIdUserSet) Throw(nameof(sender_id)); + if (_sfMaxBytesUserSet) Throw(nameof(sf_max_bytes)); + if (_sfMaxTotalBytesUserSet) Throw(nameof(sf_max_total_bytes)); + if (_sfDurabilityUserSet) Throw(nameof(sf_durability)); + if (_sfAppendDeadlineUserSet) Throw(nameof(sf_append_deadline_millis)); + if (_reconnectMaxDurationUserSet) Throw(nameof(reconnect_max_duration_millis)); + if (_reconnectInitialBackoffUserSet) Throw(nameof(reconnect_initial_backoff_millis)); + if (_reconnectMaxBackoffUserSet) Throw(nameof(reconnect_max_backoff_millis)); + if (_initialConnectModeUserSet) Throw("initial_connect_retry / initial_connect_mode"); + if (_closeFlushTimeoutUserSet) Throw(nameof(close_flush_timeout_millis)); + if (_drainOrphansUserSet) Throw(nameof(drain_orphans)); + if (_maxBackgroundDrainersUserSet) Throw(nameof(max_background_drainers)); + if (_pingTimeoutUserSet) Throw(nameof(ping_timeout)); + if (_durableAckKeepaliveIntervalUserSet) Throw(nameof(durable_ack_keepalive_interval_millis)); + if (_proxyUserSet) Throw(nameof(proxy)); + if (_errorHandlerUserSet) Throw(nameof(error_handler)); + if (_errorPolicyResolverUserSet) Throw(nameof(error_policy_resolver)); + if (_errorInboxCapacityUserSet) Throw(nameof(error_inbox_capacity)); + if (_connectionListenerInboxCapacityUserSet) Throw(nameof(connection_listener_inbox_capacity)); + if (_connectionListenerUserSet) Throw(nameof(ConnectionListener)); + if (_onServerErrorUserSet) Throw(nameof(on_server_error)); + if (_onSchemaMismatchErrorUserSet) Throw(nameof(on_schema_mismatch_error)); + if (_onParseErrorUserSet) Throw(nameof(on_parse_error)); + if (_onInternalErrorUserSet) Throw(nameof(on_internal_error)); + if (_onSecurityErrorUserSet) Throw(nameof(on_security_error)); + if (_onWriteErrorUserSet) Throw(nameof(on_write_error)); + + static void Throw(string key) => + throw new IngressError(ErrorCode.ConfigError, + $"`{key}` is only supported with the ws:: or wss:: scheme"); + } + + private void ApplyAutoFlushNormalisation() + { + if (IsWebSocket()) + { + if (!_autoFlushRowsUserSet && _autoFlushRows == 75000) _autoFlushRows = 1000; + if (!_autoFlushBytesUserSet && _autoFlushBytes == int.MaxValue) _autoFlushBytes = 8 * 1024 * 1024; + if (!_autoFlushIntervalUserSet && _autoFlushInterval == TimeSpan.FromMilliseconds(1000)) + _autoFlushInterval = TimeSpan.FromMilliseconds(100); + } + if (_autoFlush == AutoFlushType.off) + { + _autoFlushRows = -1; + _autoFlushBytes = -1; + _autoFlushInterval = TimeSpan.FromMilliseconds(-1); + } + } + + private static readonly string[] WebSocketOnlyKeys = BuildWebSocketOnlyKeys(); + + private static string[] BuildWebSocketOnlyKeys() + { + var keys = new List + { + "request_durable_ack", "transaction", + "sf_dir", "sender_id", "sf_max_bytes", "sf_max_total_bytes", "sf_durability", + "sf_append_deadline_millis", "reconnect_max_duration_millis", "reconnect_initial_backoff_millis", + "reconnect_max_backoff_millis", "initial_connect_retry", "initial_connect_mode", + "close_flush_timeout_millis", "drain_orphans", "max_background_drainers", "ping_timeout", + "durable_ack_keepalive_interval_millis", "proxy", + "error_handler", "error_policy_resolver", "error_inbox_capacity", "connection_listener_inbox_capacity", + "on_server_error", "on_schema_mismatch_error", "on_schema_error", "on_parse_error", "on_internal_error", + "on_security_error", "on_write_error", + }; + // Egress query-client keys are ws-only too: accepted-and-ignored on ws/wss, rejected on http/tcp. + keys.AddRange(QwpConnectStringKeys.EgressOnly); + return keys.ToArray(); } /// @@ -162,15 +839,38 @@ public ProtocolVersion protocol_version public string addr { get => _addr; - set => _addr = value; + set + { + _addr = value; + _addresses.Clear(); + if (!string.IsNullOrEmpty(value)) + { + foreach (var piece in value.Split(',')) + { + var trimmed = piece.Trim(); + if (trimmed.Length == 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"empty entry in comma-separated `addr={value}`"); + } + _addresses.Add(trimmed); + } + if (_addresses.Count > 0) + { + _addr = _addresses[0]; + } + } + } } /// /// List of all configured addresses for failover. /// /// - /// Contains all addresses specified via multiple `addr` entries in the configuration string. - /// The list is never empty; it contains at least the primary address. + /// Populated from addr=h1:p1,h2:p2,.... Supported on every protocol; the list is + /// never empty. For ws/wss the sender walks the list with role-aware skipping + /// (REPLICA and PRIMARY_CATCHUP upgrade rejections are detected via + /// 421 Misdirected Request + X-QuestDB-Role and rotated past). /// [JsonIgnore] public IReadOnlyList addresses => _addresses.AsReadOnly(); @@ -201,7 +901,7 @@ public AutoFlushType auto_flush public int auto_flush_rows { get => _autoFlushRows; - set => _autoFlushRows = value; + set { _autoFlushRows = value; _autoFlushRowsUserSet = true; } } /// @@ -211,7 +911,7 @@ public int auto_flush_rows public int auto_flush_bytes { get => _autoFlushBytes; - set => _autoFlushBytes = value; + set { _autoFlushBytes = value; _autoFlushBytesUserSet = true; } } /// @@ -227,7 +927,7 @@ public int auto_flush_bytes public TimeSpan auto_flush_interval { get => _autoFlushInterval; - set => _autoFlushInterval = value; + set { _autoFlushInterval = value; _autoFlushIntervalUserSet = true; } } /// @@ -251,6 +951,20 @@ public bool gzip set => _gzip = value; } + /// + /// When true, a with kind is + /// converted to UTC (via ) before its wire timestamp is + /// computed; and values + /// are written unchanged. Defaults to false, which writes the raw wall-clock ticks of a + /// local value as-is (i.e. as though it were UTC). Applies to every ingest transport + /// (HTTP / TCP / WS); values are timezone-aware and unaffected. + /// + public bool convert_local_to_utc + { + get => _convertLocalToUtc; + set => _convertLocalToUtc = value; + } + /// /// Initial buffer size for the ILP rows in bytes. /// Defaults to 64 KiB. @@ -334,41 +1048,20 @@ public string? token set => _token = value; } + /// - /// Used in other ILP clients for authentication. + /// Timeout for authentication requests. + /// Defaults to 15 seconds. /// - [Obsolete] - [JsonIgnore] - public string? token_x + public TimeSpan auth_timeout { - get => _tokenX; - set => _tokenX = value; + get => _authTimeout; + set => _authTimeout = value; } /// - /// Used in other ILP clients for authentication. - /// - [Obsolete] - [JsonIgnore] - public string? token_y - { - get => _tokenY; - set => _tokenY = value; - } - - /// - /// Timeout for authentication requests. - /// Defaults to 15 seconds. - /// - public TimeSpan auth_timeout - { - get => _authTimeout; - set => _authTimeout = value; - } - - /// - /// Specifies a minimum expect network throughput when sending data to QuestDB. - /// Defaults to 100 KiB + /// Specifies a minimum expect network throughput when sending data to QuestDB. + /// Defaults to 100 KiB /// /// /// Requests sent to the database vary in size. Therefore, a single fixed timeout value @@ -388,7 +1081,7 @@ public int request_min_throughput /// /// Specifies a base interval for timing out HTTP requests to QuestDB. - /// Defaults to 10000 ms. + /// Defaults to 30000 ms. /// /// /// This value is combined with a dynamic timeout value generated based on how large the payload is. @@ -431,17 +1124,8 @@ public TlsVerifyType tls_verify } /// - /// Not in use - /// - [Obsolete] - public string? tls_ca - { - get => _tlsCa; - set => _tlsCa = value; - } - - /// - /// Specifies the path to a custom certificate. + /// Path to a custom CA bundle used to verify the server certificate. Accepts PEM + /// (.pem / .crt) or PFX/PKCS#12 (.pfx / .p12); the format is selected by file extension. /// public string? tls_roots { @@ -450,7 +1134,8 @@ public string? tls_roots } /// - /// Specifies the path to a custom certificate password. + /// Optional password for the PFX/PKCS#12 file referenced by . + /// Ignored for PEM bundles (PEM has no password concept here). /// [JsonIgnore] public string? tls_roots_password @@ -478,11 +1163,352 @@ public TimeSpan pool_timeout set => _poolTimeout = value; } + /// + /// If true, requests STATUS_DURABLE_ACK frames from the server via the + /// X-QWP-Request-Durable-Ack upgrade header. Off by default. + /// + public bool request_durable_ack + { + get => _requestDurableAck; + set { _requestDurableAck = value; _requestDurableAckUserSet = true; } + } + + /// + /// Enables WebSocket transactional mode (connect-string key transaction=on). Auto-flush + /// ships frames with FLAG_DEFER_COMMIT so the server appends without committing; an + /// explicit Send()/Commit() (or dispose) ships a non-deferred frame that triggers + /// the server-side WAL commit. Lets a producer stage a dataset larger than the server recv + /// buffer and commit it atomically per table. WebSocket-only; off by default. + /// + public bool transaction + { + get => _transaction; + set { _transaction = value; _transactionUserSet = true; } + } + + /// + /// Store-and-forward root directory. Setting this enables SF mode; the slot lives at + /// <sf_dir>/<sender_id>/. null (default) keeps the sender on the + /// in-memory async queue. + /// + public string? sf_dir + { + get => _sfDir; + set { _sfDir = value; _sfDirUserSet = true; } + } + + /// Slot identifier within . Defaults to "default". + public string sender_id + { + get => _senderId; + set { SetSenderId(value); _senderIdUserSet = true; } + } + + private void SetSenderId(string value) + { + if (string.IsNullOrEmpty(value)) + { + throw new IngressError(ErrorCode.ConfigError, "`sender_id` must not be empty"); + } + + if (value.IndexOfAny(new[] { '/', '\\', '\0' }) >= 0 + || value.Contains("..", StringComparison.Ordinal) + || (value.Length >= 2 && value[1] == ':') + || Path.IsPathRooted(value)) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sender_id` must be a single path segment without separators, drive letters, or `..` (got `{value}`)"); + } + + _senderId = value; + } + + /// Per-segment rotation threshold in bytes. Defaults to 4 MiB. + public long sf_max_bytes + { + get => _sfMaxBytes; + set { _sfMaxBytes = value; _sfMaxBytesUserSet = true; } + } + + /// + /// Hard cap on total bytes across all live segments in the slot. Defaults to 128 MiB without + /// set, 10 GiB with it. When the cap is hit the producer hits backpressure. + /// + public long sf_max_total_bytes + { + get => _sfMaxTotalBytes; + set + { + _sfMaxTotalBytes = value; + _sfMaxTotalBytesUserSet = true; + } + } + + /// Durability tier. v1 only accepts "memory". + public string sf_durability + { + get => _sfDurability; + set { _sfDurability = value; _sfDurabilityUserSet = true; } + } + + /// + /// Maximum time the producer waits at the SF backpressure barrier before throwing. + /// Defaults to 30 s. + /// + public TimeSpan sf_append_deadline_millis + { + get => _sfAppendDeadline; + set { _sfAppendDeadline = value; _sfAppendDeadlineUserSet = true; } + } + + /// Total wall-clock budget for a single reconnect run. Defaults to 5 min. + public TimeSpan reconnect_max_duration_millis + { + get => _reconnectMaxDuration; + set { _reconnectMaxDuration = value; _reconnectMaxDurationUserSet = true; } + } + + /// First reconnect backoff. Defaults to 100 ms. + public TimeSpan reconnect_initial_backoff_millis + { + get => _reconnectInitialBackoff; + set { _reconnectInitialBackoff = value; _reconnectInitialBackoffUserSet = true; } + } + + /// Maximum reconnect backoff after exponential growth. Defaults to 5 s. + public TimeSpan reconnect_max_backoff_millis + { + get => _reconnectMaxBackoff; + set { _reconnectMaxBackoff = value; _reconnectMaxBackoffUserSet = true; } + } + + /// + /// Legacy bool view of . false maps to + /// ; true maps to . + /// The value is only reachable via + /// or the connect-string key initial_connect_retry=async. + /// + public bool initial_connect_retry + { + get => _initialConnectMode != InitialConnectMode.off; + set + { + _initialConnectMode = value ? InitialConnectMode.on : InitialConnectMode.off; + _initialConnectModeUserSet = true; + } + } + + /// + /// First-connect retry policy. See for value semantics. + /// The connect-string key is initial_connect_retry for cross-client compatibility; + /// it accepts off/false, on/true/sync, and async. + /// + public InitialConnectMode initial_connect_mode + { + get => _initialConnectMode; + set { _initialConnectMode = value; _initialConnectModeUserSet = true; } + } + + /// + /// Optional callback invoked when the SF cursor engine observes a server-side rejection + /// or reaches a terminal state. Fires for both + /// and outcomes. Programmatic-only. + /// When unset, the engine logs notifications via + /// so failures aren't silently lost. + /// + [JsonIgnore] + public SenderErrorHandler? error_handler + { + get => _errorHandler; + set { _errorHandler = value; _errorHandlerUserSet = true; } + } + + /// + /// Optional resolver overriding the per- default policy. + /// and + /// are always + /// regardless of the resolver. Programmatic-only. + /// + [JsonIgnore] + public SenderErrorPolicyResolver? error_policy_resolver + { + get => _errorPolicyResolver; + set { _errorPolicyResolver = value; _errorPolicyResolverUserSet = true; } + } + + /// + /// Bounded inbox capacity for the async error dispatcher. When the inbox fills, surplus + /// notifications are dropped and counted (visible via the sender's + /// DroppedErrorNotifications accessor). Defaults to 256. + /// + public int error_inbox_capacity + { + get => _errorInboxCapacity; + set { _errorInboxCapacity = value; _errorInboxCapacityUserSet = true; } + } + + /// + /// Bounded inbox capacity for the async connection-event dispatcher. When the inbox fills, + /// surplus events are dropped and counted (visible via + /// ). + /// Defaults to 256. WS-only. + /// + public int connection_listener_inbox_capacity + { + get => _connectionListenerInboxCapacity; + set { _connectionListenerInboxCapacity = value; _connectionListenerInboxCapacityUserSet = true; } + } + + /// + /// Programmatic-only registration of an . + /// Not a connect-string key (a callback can't be expressed in a string). WS-only; ignored on + /// other transports. + /// + [JsonIgnore] + public QuestDB.Senders.ISenderConnectionListener? ConnectionListener + { + get => _connectionListener; + set { _connectionListener = value; _connectionListenerUserSet = true; } + } + + /// + /// Default policy for any overridable category that has no per-category override. + /// Connect-string accepts halt, drop, drop_and_continue. + /// + public SenderErrorPolicy? on_server_error + { + get => _onServerError; + set { _onServerError = value; _onServerErrorUserSet = true; } + } + + /// Override for . Default: drop_and_continue. + public SenderErrorPolicy? on_schema_mismatch_error + { + get => _onSchemaMismatchError; + set { _onSchemaMismatchError = value; _onSchemaMismatchErrorUserSet = true; } + } + + /// Override for . Default: halt. + public SenderErrorPolicy? on_parse_error + { + get => _onParseError; + set { _onParseError = value; _onParseErrorUserSet = true; } + } + + /// Override for . Default: halt. + public SenderErrorPolicy? on_internal_error + { + get => _onInternalError; + set { _onInternalError = value; _onInternalErrorUserSet = true; } + } + + /// Override for . Default: halt. + public SenderErrorPolicy? on_security_error + { + get => _onSecurityError; + set { _onSecurityError = value; _onSecurityErrorUserSet = true; } + } + + /// Override for . Default: drop_and_continue. + public SenderErrorPolicy? on_write_error + { + get => _onWriteError; + set { _onWriteError = value; _onWriteErrorUserSet = true; } + } + + /// + /// Maximum time to wait for unacked SF frames to drain on Sender.Dispose. + /// Defaults to 60 s, matching the Java client — a wide default so close() does not + /// silently drop unacked rows on slow/backlogged consumers. + /// + public TimeSpan close_flush_timeout_millis + { + get => _closeFlushTimeout; + set { _closeFlushTimeout = value; _closeFlushTimeoutUserSet = true; } + } + + /// + /// If true, the sender scans at startup for sibling slot + /// directories left behind by crashed senders, claims their locks, and drains them in the + /// background. Off by default. + /// + public bool drain_orphans + { + get => _drainOrphans; + set { _drainOrphans = value; _drainOrphansUserSet = true; } + } + + /// Cap on concurrent orphan-drain workers. Defaults to 4. + public int max_background_drainers + { + get => _maxBackgroundDrainers; + set { _maxBackgroundDrainers = value; _maxBackgroundDrainersUserSet = true; } + } + + /// + /// Maximum time a single Ping / PingAsync call will wait for in-flight ACKs to + /// drain. Defaults to 5 s. + /// + public TimeSpan ping_timeout + { + get => _pingTimeout; + set { _pingTimeout = value; _pingTimeoutUserSet = true; } + } + + /// + /// Cadence of WebSocket PING used to force the server to flush pending durable-ack frames. + /// Zero or negative disables the keepalive. Defaults to 200 ms. Only consulted when + /// is on; in non-durable-ack mode no PING is sent + /// regardless of this value. Note that + /// only exposes a fixed + /// , so the + /// PING fires on any idle period — including when no durable-ack is pending. + /// + public TimeSpan durable_ack_keepalive_interval_millis + { + get => _durableAckKeepaliveInterval; + set { _durableAckKeepaliveInterval = value; _durableAckKeepaliveIntervalUserSet = true; } + } + + /// + /// Proxy override for the WebSocket transport. Accepts disable (no proxy, the default — + /// long-lived WS connections rarely survive HTTP proxies), system (use the system + /// default proxy), or an explicit proxy URI like http://proxy.local:3128. Ignored on + /// non-WS transports. + /// + public string? proxy + { + get => _proxy; + set + { + _proxy = value; + _proxyUserSet = true; + } + } + + /// + /// Client zone identifier (opaque, case-insensitive; e.g. eu-west-1a). When set, the + /// WebSocket sender prefers endpoints whose server-advertised zone_id matches when + /// ranking multiple for failover. Ignored on non-WS transports. + /// + public string? zone + { + get => _zone; + set => _zone = value; + } + /// /// Wrapper to extract the Host from . /// [JsonIgnore] - public string Host => addr.Contains(':') ? addr.Split(':')[0] : addr; + public string Host + { + get + { + SplitHostPort(addr, out var host, out _); + return host; + } + } /// /// Wrapper to extract the Port from . @@ -492,15 +1518,18 @@ public int Port { get { - if (addr.Contains(':')) + SplitHostPort(addr, out _, out var port); + if (port >= 0) { - return int.Parse(addr.Split(':')[1]); + return port; } switch (protocol) { case ProtocolType.http: case ProtocolType.https: + case ProtocolType.ws: + case ProtocolType.wss: return 9000; case ProtocolType.tcp: case ProtocolType.tcps: @@ -511,9 +1540,112 @@ public int Port } } + private static void RejectControlChars(string name, string? value) + { + if (string.IsNullOrEmpty(value)) return; + foreach (var c in value) + { + if (c < 0x20 || c == 0x7F) + { + throw new IngressError(ErrorCode.ConfigError, + $"`{name}` contains a control character (0x{(int)c:X2})"); + } + } + } + + internal Uri BuildUri(int addressIndex, string path) + { + if (addressIndex < 0 || addressIndex >= _addresses.Count) + { + throw new ArgumentOutOfRangeException(nameof(addressIndex)); + } + SplitHostPort(_addresses[addressIndex], out var host, out var port); + if (port < 0) + { + port = protocol switch + { + ProtocolType.http or ProtocolType.https or ProtocolType.ws or ProtocolType.wss => 9000, + ProtocolType.tcp or ProtocolType.tcps => 9009, + _ => throw new NotImplementedException(), + }; + } + var scheme = protocol switch + { + ProtocolType.wss => "wss", + ProtocolType.ws => "ws", + ProtocolType.https => "https", + ProtocolType.http => "http", + ProtocolType.tcps => "tcps", + ProtocolType.tcp => "tcp", + _ => throw new NotImplementedException(), + }; + return new Uri($"{scheme}://{host}:{port}{path}"); + } + + private static void SplitHostPort(string addr, out string host, out int port) + { + int colon; + if (addr.Length > 0 && addr[0] == '[') + { + var close = addr.IndexOf(']'); + if (close < 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed address `{addr}`: missing `]` in IPv6 literal"); + } + host = addr.Substring(0, close + 1); + if (close + 1 == addr.Length) + { + port = -1; + return; + } + if (addr[close + 1] != ':') + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed address `{addr}`: expected `:` after `]`"); + } + colon = close + 1; + } + else + { + var firstColon = addr.IndexOf(':'); + if (firstColon < 0) + { + host = addr; + port = -1; + return; + } + + if (addr.IndexOf(':', firstColon + 1) >= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed address `{addr}`: too many colons (bracket IPv6 literals as `[addr]:port`)"); + } + + colon = firstColon; + host = addr.Substring(0, firstColon); + } + + if (string.IsNullOrWhiteSpace(host)) + { + throw new IngressError(ErrorCode.ConfigError, $"malformed address `{addr}`: empty host"); + } + var portStr = addr.Substring(colon + 1); + if (!int.TryParse(portStr, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out port) + || port <= 0 || port > 65535) + { + throw new IngressError(ErrorCode.ConfigError, + $"malformed address `{addr}`: invalid port `{portStr}`"); + } + } + /// - /// Specifies a client certificate to be used for TLS authentication. + /// Specifies a client certificate to be used for TLS authentication. Set programmatically + /// only; excluded from (an has no + /// connect-string representation). /// + [JsonIgnore] public X509Certificate2? client_cert { get => _clientCert; @@ -522,12 +1654,24 @@ public X509Certificate2? client_cert private void ParseIntWithDefault(string name, string defaultValue, out int field) { - if (!int.TryParse(ReadOptionFromBuilder(name) ?? defaultValue, out field)) + if (!int.TryParse(ReadOptionFromBuilder(name) ?? defaultValue, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out field)) { throw new IngressError(ErrorCode.ConfigError, $"`{name}` should be convertible to an int."); } } + private void ParseLongWithDefault(string name, string defaultValue, out long field) + { + if (!long.TryParse(ReadOptionFromBuilder(name) ?? defaultValue, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out field)) + { + throw new IngressError(ErrorCode.ConfigError, $"`{name}` should be convertible to a long."); + } + } + private void ParseMillisecondsWithDefault(string name, string defaultValue, out TimeSpan field) { ParseIntWithDefault(name, defaultValue, out var ms); @@ -545,10 +1689,32 @@ private void ParseEnumWithDefault(string name, string defaultValue, out T fie private void ParseBoolWithDefault(string name, string defaultValue, out bool field) { - if (!bool.TryParse(ReadOptionFromBuilder(name) ?? defaultValue, out field)) + var raw = ReadOptionFromBuilder(name) ?? defaultValue; + if (!TryParseInteropBool(raw, out field)) { - throw new IngressError(ErrorCode.ConfigError, $"`{name}` should be convertible to an bool."); + throw new IngressError(ErrorCode.ConfigError, + $"`{name}` should be a boolean (true/false/on/off), got `{raw}`"); + } + } + + internal static bool TryParseInteropBool(string raw, out bool value) + { + if (string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "on", StringComparison.OrdinalIgnoreCase)) + { + value = true; + return true; } + + if (string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase) || + string.Equals(raw, "off", StringComparison.OrdinalIgnoreCase)) + { + value = false; + return true; + } + + value = false; + return false; } private void ParseStringWithDefault(string name, string? defaultValue, out string? field) @@ -556,41 +1722,58 @@ private void ParseStringWithDefault(string name, string? defaultValue, out strin field = ReadOptionFromBuilder(name) ?? defaultValue; } - private void ParseIntThatMayBeOff(string name, string? defaultValue, out int field) + private void ParseIntThatMayBeOff(string name, string? defaultValue, out int field, bool rejectLiteralZero = false) { - var option = ReadOptionFromBuilder(name) ?? defaultValue; + var rawOption = ReadOptionFromBuilder(name); + var option = rawOption ?? defaultValue; if (option is "off") { field = -1; + return; } - else + + ParseIntWithDefault(name, defaultValue!, out field); + if (field == 0) { - ParseIntWithDefault(name, defaultValue!, out field); + if (rejectLiteralZero && rawOption is not null) + { + throw new IngressError(ErrorCode.ConfigError, $"invalid `{name}`: must be > 0 or `off`"); + } + field = -1; } } - private void ParseMillisecondsThatMayBeOff(string name, string? defaultValue, out TimeSpan field) + private void ParseMillisecondsThatMayBeOff(string name, string? defaultValue, out TimeSpan field, bool rejectLiteralZero = false) { - var option = ReadOptionFromBuilder(name) ?? defaultValue; + var rawOption = ReadOptionFromBuilder(name); + var option = rawOption ?? defaultValue; if (option is "off") { field = TimeSpan.FromMilliseconds(-1); + return; } - else + + ParseMillisecondsWithDefault(name, defaultValue!, out field); + if (field == TimeSpan.Zero) { - ParseMillisecondsWithDefault(name, defaultValue!, out field); + if (rejectLiteralZero && rawOption is not null) + { + throw new IngressError(ErrorCode.ConfigError, $"invalid `{name}`: must be > 0 or `off`"); + } + field = TimeSpan.FromMilliseconds(-1); } } private void ReadConfigStringIntoBuilder(string confStr) { - if (!confStr.Contains("::")) + var sep = confStr.IndexOf("::", StringComparison.Ordinal); + if (sep <= 0) { throw new IngressError(ErrorCode.ConfigError, "Config string must contain a protocol, separated by `::`"); } - var splits = confStr.Split("::"); - var paramString = splits[1]; + var protocolPart = confStr.Substring(0, sep); + var paramString = confStr.Substring(sep + 2); // Parse addresses manually before using DbConnectionStringBuilder // because DbConnectionStringBuilder only keeps the last value for duplicate keys @@ -599,13 +1782,32 @@ private void ReadConfigStringIntoBuilder(string confStr) { if (string.IsNullOrWhiteSpace(param)) continue; - var kvp = param.Split('='); - if (kvp.Length == 2 && kvp[0].Trim() == "addr") + var idx = param.IndexOf('='); + if (idx < 0) { - var addrValue = kvp[1].Trim(); - if (!string.IsNullOrEmpty(addrValue)) + throw new IngressError(ErrorCode.ConfigError, + $"Malformed config entry `{param.Trim()}`; expected `key=value`"); + } + + var key = param.Substring(0, idx).Trim(); + var value = param.Substring(idx + 1).Trim(); + if (key.Length == 0 || value.Length == 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"Malformed config entry `{param.Trim()}`; key and value must both be non-empty"); + } + + if (key == "addr") + { + foreach (var piece in value.Split(',')) { - _addresses.Add(addrValue); + var trimmed = piece.Trim(); + if (trimmed.Length == 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"empty entry in comma-separated `addr={value}`"); + } + _addresses.Add(trimmed); } } } @@ -615,13 +1817,12 @@ private void ReadConfigStringIntoBuilder(string confStr) ConnectionString = paramString, }; - VerifyCorrectKeysInConfigString(); - - _connectionStringBuilder.Add("protocol", splits[0]); + _connectionStringBuilder.Add("protocol", protocolPart); } private string? ReadOptionFromBuilder(string name) { + if (_connectionStringBuilder is null) return null; _connectionStringBuilder.TryGetValue(name, out var value); return (string?)value; } @@ -640,24 +1841,94 @@ internal bool IsHttp() internal bool IsTcp() { - return !IsHttp(); + switch (protocol) + { + case ProtocolType.tcp: + case ProtocolType.tcps: + return true; + default: + return false; + } + } + + internal bool IsWebSocket() + { + switch (protocol) + { + case ProtocolType.ws: + case ProtocolType.wss: + return true; + default: + return false; + } } /// /// Serialises the object into a config string, minus secrets. /// + private static readonly HashSet SecretPropertyNames = new(StringComparer.Ordinal) + { + nameof(password), + nameof(token), + nameof(tls_roots_password), + }; + + private const string SecretRedaction = "***"; + + /// + /// Renders the options as a connection string, safe to log: the secret fields + /// (password, token, tls_roots_password) are omitted entirely, so this + /// output never leaks credentials. + /// + /// + /// Re-parsing via round-trips only for configs without + /// authentication. An authenticated config does not round-trip: the secret is stripped + /// but its paired username is still emitted, so the re-parse throws (Basic auth's + /// username with no password, or TCP's username with no token). + /// Use this for diagnostics and logging, not to clone or persist a credentialed config. + /// public override string ToString() { var builder = new DbConnectionStringBuilder(); foreach (var prop in GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public).OrderBy(x => x.Name)) { + // WS-only keys would fail re-parse on non-WS protocols; skip to keep ToString round-trip. + if (!IsWebSocket() && Array.IndexOf(WebSocketOnlyKeys, prop.Name) >= 0) + { + continue; + } + // ILP HTTP/TCP-only keys would fail re-parse on WS protocols; skip to keep ToString + // round-trip. auth_timeout is the exception: on WS it is re-emitted below under its + // QWP name auth_timeout_ms, which re-parses cleanly, so it must not be skipped. + if (IsWebSocket() && prop.Name != nameof(auth_timeout) + && Array.IndexOf(IlpHttpOnlyKeys, prop.Name) >= 0) + { + continue; + } + // exclude properties if (prop.IsDefined(typeof(CompilerGeneratedAttribute), false)) { continue; } + // addr is emitted as a single comma-separated entry preserving original order. + if (prop.Name == nameof(addr)) + { + continue; + } + + if (prop.Name == nameof(initial_connect_retry)) + { + continue; + } + + if (SecretPropertyNames.Contains(prop.Name)) + { + continue; + } + if (prop.IsDefined(typeof(JsonIgnoreAttribute), false)) { continue; @@ -673,44 +1944,95 @@ public override string ToString() continue; } - if (value != null) + if (value is null) { - if (value is TimeSpan span) - { - builder.Add(prop.Name, span.TotalMilliseconds); - } - else if (value is string str && !string.IsNullOrEmpty(str)) - { - builder.Add(prop.Name, value); - } - else - { - builder.Add(prop.Name, value); - } + continue; + } + + var emitName = prop.Name switch + { + nameof(initial_connect_mode) => "initial_connect_retry", + nameof(auth_timeout) when IsWebSocket() => "auth_timeout_ms", + _ => prop.Name, + }; + + if (value is TimeSpan span) + { + // Cast to long-millis for round-trip safety; the parser is integer-valued. + builder.Add(emitName, + ((long)span.TotalMilliseconds).ToString(System.Globalization.CultureInfo.InvariantCulture)); } + else if (value is bool onOff && Array.IndexOf(OnOffBoolKeys, prop.Name) >= 0) + { + // Emit the on/off vocabulary the parser documents, not the CLR True/False literal. + builder.Add(emitName, onOff ? "on" : "off"); + } + else if (value is IFormattable formattable) + { + builder.Add(emitName, formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture)); + } + else if (value is string str && !string.IsNullOrEmpty(str)) + { + builder.Add(emitName, value); + } + else + { + builder.Add(emitName, value); + } + } + + var connectionString = builder.ConnectionString; + if (_addresses.Count > 0) + { + var addrValue = string.Join(",", _addresses); + connectionString = $"addr={addrValue};{connectionString}"; } - return $"{protocol.ToString()}::{builder.ConnectionString};"; + return $"{protocol.ToString()}::{connectionString};"; } - private void VerifyCorrectKeysInConfigString() + /// + /// Record-synthesised member printer override; redacts the same secrets + /// redacts so debugger / logging output never leaks them. + /// + protected virtual bool PrintMembers(StringBuilder sb) { - foreach (string key in _connectionStringBuilder.Keys) + var props = GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public).OrderBy(p => p.Name); + var first = true; + foreach (var prop in props) { - if (!keySet.Contains(key)) + if (prop.IsDefined(typeof(CompilerGeneratedAttribute), false)) continue; + var isSecret = SecretPropertyNames.Contains(prop.Name); + if (prop.IsDefined(typeof(JsonIgnoreAttribute), false) && !isSecret) continue; + object? value; + try { value = prop.GetValue(this); } catch { continue; } + if (!first) sb.Append(", "); + first = false; + sb.Append(prop.Name).Append(" = "); + if (isSecret) + { + sb.Append(value is null ? "null" : SecretRedaction); + } + else { - throw new IngressError(ErrorCode.ConfigError, $"Invalid property: `{key}`"); + sb.Append(value ?? "null"); } } + return !first; } + private void ParseAddresses() { - // If no addresses were parsed from config string, use the primary addr if (_addresses.Count == 0) { _addresses.Add(_addr); } + else + { + // _addr from DbConnectionStringBuilder still has the raw `h1:p,h2:p`; normalise to first. + _addr = _addresses[0]; + } } /// diff --git a/src/net-questdb-client/net-questdb-client.csproj b/src/net-questdb-client/net-questdb-client.csproj index bbe90d1a..532b1775 100644 --- a/src/net-questdb-client/net-questdb-client.csproj +++ b/src/net-questdb-client/net-questdb-client.csproj @@ -1,6 +1,8 @@ true + + true QuestDB @@ -9,15 +11,22 @@ latest true QuestDB client - Simple QuestDB ILP protocol client + QuestDB .NET client. Supports ILP over HTTP/TCP and the QWP columnar binary protocol over WebSocket (ingest sender + egress query client + store-and-forward). QuestDB https://questdb.io - Apache 2.0 + Apache-2.0 https://github.com/questdb/net-questdb-client - QuestDB, ILP + QuestDB, ILP, QWP, WebSocket, columnar, ingest, query, store-and-forward QuestDB Limited - 3.2.0 + 4.0.0 true net6.0;net7.0;net8.0;net9.0;net10.0 + + + + + + + diff --git a/system_test/enterprise_e2e/conftest.py b/system_test/enterprise_e2e/conftest.py new file mode 100644 index 00000000..1c3f721d --- /dev/null +++ b/system_test/enterprise_e2e/conftest.py @@ -0,0 +1,225 @@ +""" +Pytest root config for net-questdb-client Enterprise e2e tests. + +Registers the Enterprise shared_fixtures plugin (server_factory, +scenario_dir, obj_store, etc.) and adds a ``net_sidecar`` fixture +that builds and launches the .NET sidecar console app. + +The QUESTDB_ENTERPRISE_E2E_DIR environment variable must point at +the ``questdb-ent/e2e`` directory in the Enterprise checkout so the +plugin module is importable. +""" + +from __future__ import annotations + +import logging +import os +import signal +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from threading import Thread +from typing import IO, Iterator, Optional + +import pytest +import sys + +_ent_e2e = os.environ.get("QUESTDB_ENTERPRISE_E2E_DIR") +if _ent_e2e: + sys.path.insert(0, _ent_e2e) + +pytest_plugins = ("lib.shared_fixtures",) + +LOG = logging.getLogger(__name__) + +SIDECAR_DIR = Path(__file__).resolve().parent / "sidecar" +SIDECAR_DLL = SIDECAR_DIR / "bin" / "Release" / "net10.0" / "net-questdb-client-e2e-sidecar.dll" + + +class NetSidecarError(RuntimeError): + pass + + +@dataclass +class NetSidecarStats: + acked: int + sent: int + acks: int + reconn_attempts: int + reconn_succ: int + server_errors: int + + +@dataclass +class NetSidecar: + log_dir: Path + name: str = "net-sidecar" + + process: Optional[subprocess.Popen] = field(default=None, init=False, repr=False) + _stderr_thread: Optional[Thread] = field(default=None, init=False, repr=False) + + def start(self, *, ready_timeout: float = 60.0) -> None: + if self.process is not None: + raise RuntimeError(f"sidecar {self.name!r} already started") + + dll = SIDECAR_DLL + if not dll.exists(): + raise FileNotFoundError( + f"sidecar DLL not found at {dll}; " + f"run 'dotnet build -c Release' in {SIDECAR_DIR} first" + ) + + cmd = ["dotnet", str(dll)] + self.log_dir.mkdir(parents=True, exist_ok=True) + stderr_log = open(self.log_dir / f"{self.name}.stderr.log", "w", encoding="utf-8") + + LOG.info("starting .NET sidecar %s", self.name) + self.process = subprocess.Popen( + cmd, + env=os.environ.copy(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + + self._stderr_thread = _drain(self.process.stderr, stderr_log, f"{self.name}-stderr") + + deadline = time.monotonic() + ready_timeout + while True: + if self.process.poll() is not None: + raise RuntimeError( + f"sidecar {self.name!r} exited prematurely " + f"(code {self.process.returncode}); see " + f"{self.log_dir / f'{self.name}.stderr.log'}" + ) + if time.monotonic() > deadline: + raise TimeoutError( + f"sidecar {self.name!r} did not READY within {ready_timeout}s" + ) + line = _readline(self.process.stdout, 0.5) + if line is None: + continue + line = line.strip() + if line == "READY": + break + LOG.warning("sidecar %s pre-READY: %r", self.name, line) + + def stop(self) -> None: + if self.process is None or self.process.poll() is not None: + return + try: + self._send("EXIT") + except (BrokenPipeError, OSError): + pass + try: + self.process.wait(timeout=15) + except subprocess.TimeoutExpired: + LOG.warning("sidecar %s did not exit after EXIT, escalating to SIGKILL", self.name) + self.process.kill() + self.process.wait(timeout=5) + + def kill_9(self) -> None: + if self.process is None or self.process.poll() is not None: + return + LOG.info("kill -9 sidecar %s pid=%d", self.name, self.process.pid) + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) + except ProcessLookupError: + pass + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + LOG.error("sidecar %s did not exit after SIGKILL within 10s", self.name) + + # ---- protocol verbs (same as the Java sidecar) ---- + + def connect(self, connect_string: str) -> None: + self._send(f"CONNECT {connect_string}") + self._expect_ok() + + def send(self, table: str, count: int, start_index: int = 0) -> None: + self._send(f"SEND {table} {count} {start_index}") + self._expect_ok() + + def flush(self) -> int: + self._send("FLUSH") + reply = self._expect_ok() + return int(reply[0]) if reply else -1 + + def await_acked(self, fsn: int, timeout_ms: int) -> bool: + self._send(f"AWAIT_ACKED {fsn} {timeout_ms}") + reply = self._expect_ok() + return reply[0] == "true" if reply else False + + def stats(self) -> NetSidecarStats: + self._send("STATS") + reply = self._expect_ok() + kv = dict(p.split("=", 1) for p in reply if "=" in p) + return NetSidecarStats( + acked=int(kv.get("acked", -1)), + sent=int(kv.get("sent", 0)), + acks=int(kv.get("acks", 0)), + reconn_attempts=int(kv.get("reconnAttempts", 0)), + reconn_succ=int(kv.get("reconnSucc", 0)), + server_errors=int(kv.get("serverErrors", 0)), + ) + + def close(self) -> None: + self._send("CLOSE") + self._expect_ok() + + # ---- internals ---- + + def _send(self, line: str) -> None: + if self.process is None or self.process.poll() is not None: + raise RuntimeError(f"sidecar {self.name!r} is not running") + assert self.process.stdin is not None + self.process.stdin.write((line + "\n").encode("utf-8")) + self.process.stdin.flush() + + def _expect_ok(self) -> list[str]: + if self.process is None: + raise RuntimeError("sidecar not running") + line = _readline(self.process.stdout, 60.0) + if line is None: + raise RuntimeError("sidecar produced no reply (timeout or EOF)") + line = line.strip() + if line.startswith("OK"): + return line.split()[1:] + if line.startswith("ERR"): + raise NetSidecarError(line[len("ERR "):]) + raise RuntimeError(f"unexpected sidecar reply: {line!r}") + + +def _readline(stream: IO[bytes], timeout: float) -> Optional[str]: + import select + readable, _, _ = select.select([stream], [], [], timeout) + if not readable: + return None + line = stream.readline() + if not line: + return None + return line.decode("utf-8", errors="replace") + + +def _drain(stream: IO[bytes], sink, label: str) -> Thread: + def _run(): + for raw in stream: + sink.write(raw.decode("utf-8", errors="replace")) + sink.close() + + t = Thread(target=_run, name=label, daemon=True) + t.start() + return t + + +@pytest.fixture(scope="function") +def net_sidecar(log_dir: Path) -> Iterator[NetSidecar]: + s = NetSidecar(log_dir=log_dir, name="net-sidecar") + s.start() + try: + yield s + finally: + s.stop() diff --git a/system_test/enterprise_e2e/pyproject.toml b/system_test/enterprise_e2e/pyproject.toml new file mode 100644 index 00000000..571ab4f4 --- /dev/null +++ b/system_test/enterprise_e2e/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "net-questdb-client-enterprise-e2e" +version = "0.1.0" +description = "Enterprise e2e tests for the .NET QuestDB client (QWiP durable-ack)." +requires-python = ">=3.10" +dependencies = [ + "pytest>=8.0", + "pytest-randomly>=3.15", + "psycopg[binary]>=3.1", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = [ + "-ra", + "-v", + "--strict-markers", + "--tb=short", +] +markers = [ + "net_client: .NET client e2e tests against Enterprise QuestDB", +] +log_cli = true +log_cli_level = "INFO" diff --git a/system_test/enterprise_e2e/sidecar/Program.cs b/system_test/enterprise_e2e/sidecar/Program.cs new file mode 100644 index 00000000..ac155c1b --- /dev/null +++ b/system_test/enterprise_e2e/sidecar/Program.cs @@ -0,0 +1,193 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ******************************************************************************/ + +using System.Globalization; +using QuestDB; +using QuestDB.Senders; + +// Line-protocol sidecar for the Enterprise e2e pytest harness. +// Mirrors the Java QwpSidecarMain: reads commands from stdin, +// writes "OK ..." or "ERR ..." replies to stdout. + +Console.OutputEncoding = System.Text.Encoding.UTF8; +Console.InputEncoding = System.Text.Encoding.UTF8; + +ISender? sender = null; + +Console.Out.WriteLine("READY"); +Console.Out.Flush(); + +string? line; +while ((line = Console.In.ReadLine()) != null) +{ + line = line.Trim(); + if (line.Length == 0) continue; + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var verb = parts[0].ToUpperInvariant(); + + try + { + switch (verb) + { + case "CONNECT": + { + var connectString = line[parts[0].Length..].Trim(); + await DisposeSenderAsync(sender); + sender = Sender.New(connectString); + Reply("OK"); + break; + } + + case "SEND": + { + EnsureSender(sender); + var table = parts[1]; + var count = int.Parse(parts[2], CultureInfo.InvariantCulture); + var startIndex = parts.Length > 3 + ? int.Parse(parts[3], CultureInfo.InvariantCulture) + : 0; + + for (var i = 0; i < count; i++) + { + var idx = startIndex + i; + sender!.Table(table) + .Symbol("tag", $"test_{idx}") + .Column("v", (long)idx) + .At(DateTime.UtcNow); + } + + Reply("OK"); + break; + } + + case "FLUSH": + { + EnsureSender(sender); + if (sender is IQwpWebSocketSender wsSender) + { + var fsn = await wsSender.FlushAndGetSequenceAsync(); + Reply($"OK {fsn}"); + } + else + { + await sender!.SendAsync(); + Reply("OK -1"); + } + + break; + } + + case "AWAIT_ACKED": + { + EnsureSender(sender); + var fsn = long.Parse(parts[1], CultureInfo.InvariantCulture); + var timeoutMs = int.Parse(parts[2], CultureInfo.InvariantCulture); + if (sender is IQwpWebSocketSender wsSender) + { + var reached = await wsSender.AwaitAckedFsnAsync( + fsn, TimeSpan.FromMilliseconds(timeoutMs)); + Reply($"OK {(reached ? "true" : "false")}"); + } + else + { + Reply("OK true"); + } + + break; + } + + case "STATS": + { + EnsureSender(sender); + if (sender is IQwpWebSocketSender wsSender) + { + Reply( + $"OK acked={wsSender.AckedFsn} " + + $"sent={wsSender.TotalFramesSent} " + + $"acks={wsSender.TotalAcks} " + + $"reconnAttempts={wsSender.TotalReconnectAttempts} " + + $"reconnSucc={wsSender.TotalReconnectsSucceeded} " + + $"serverErrors={wsSender.TotalServerErrors}"); + } + else + { + Reply("OK acked=-1 sent=0 acks=0 reconnAttempts=0 reconnSucc=0 serverErrors=0"); + } + + break; + } + + case "CLOSE": + { + await DisposeSenderAsync(sender); + sender = null; + Reply("OK"); + break; + } + + case "EXIT": + { + await DisposeSenderAsync(sender); + sender = null; + Reply("OK"); + return; + } + + default: + Reply($"ERR unknown verb: {verb}"); + break; + } + } + catch (Exception ex) + { + Reply($"ERR {ex.GetType().Name}: {ex.Message}"); + } +} + +await DisposeSenderAsync(sender); +return; + +static async Task DisposeSenderAsync(ISender? s) +{ + if (s == null) return; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await s.DisposeAsync(); + } + catch (OperationCanceledException) { } + catch (Exception) { } +} + +static void Reply(string msg) +{ + Console.Out.WriteLine(msg); + Console.Out.Flush(); +} + +static void EnsureSender(ISender? s) +{ + if (s == null) throw new InvalidOperationException("no active sender; call CONNECT first"); +} \ No newline at end of file diff --git a/system_test/enterprise_e2e/sidecar/net-questdb-client-e2e-sidecar.csproj b/system_test/enterprise_e2e/sidecar/net-questdb-client-e2e-sidecar.csproj new file mode 100644 index 00000000..3d1bae29 --- /dev/null +++ b/system_test/enterprise_e2e/sidecar/net-questdb-client-e2e-sidecar.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + QuestDB.E2E.Sidecar + enable + enable + false + + + + + + + \ No newline at end of file diff --git a/system_test/enterprise_e2e/tests/__init__.py b/system_test/enterprise_e2e/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/system_test/enterprise_e2e/tests/test_net_client.py b/system_test/enterprise_e2e/tests/test_net_client.py new file mode 100644 index 00000000..ef6396ae --- /dev/null +++ b/system_test/enterprise_e2e/tests/test_net_client.py @@ -0,0 +1,201 @@ +""" +Deterministic failover tests for the .NET QWiP client against +QuestDB Enterprise. + +Each test mirrors the pattern in questdb-ent/e2e/tests/test_failover.py: +start a primary, send rows via the .NET sidecar, kill -9 the primary, +start a successor, and verify no rows were lost. +""" + +from __future__ import annotations + +import logging +import shutil +import time +from pathlib import Path + +import pytest + +from lib.obj_store import ObjStore +from lib.pg_query import wait_for_dense_sequence +from lib.server import wait_port_free + +LOG = logging.getLogger(__name__) + + +def _connect_string(http_port: int, sf_dir: Path, *, request_durable_ack: bool = True, + reconnect_max_ms: int = 60_000, + close_flush_timeout_ms: int = 5_000) -> str: + parts = [ + f"ws::addr=127.0.0.1:{http_port}", + "user=admin", + "password=quest", + f"sf_dir={sf_dir}", + f"reconnect_max_duration_millis={reconnect_max_ms}", + f"close_flush_timeout_millis={close_flush_timeout_ms}", + ] + if request_durable_ack: + parts.append("request_durable_ack=on") + return ";".join(parts) + ";" + + +@pytest.mark.net_client +def test_kill9_primary_failover_no_data_loss(server_factory, net_sidecar, + obj_store: ObjStore, scenario_dir: Path) -> None: + """Kill -9 P1 mid-flight, verify P2 has every row.""" + table = "net_trades_failover" + row_count = 50 + sf_dir = scenario_dir / "sf" + + p1 = server_factory("p1") + p1_ports = p1.start() + + net_sidecar.connect(_connect_string(p1_ports.http, sf_dir)) + net_sidecar.send(table, count=row_count, start_index=0) + net_sidecar.flush() + + time.sleep(0.5) + + p1.kill_9() + wait_port_free(p1_ports.http) + wait_port_free(p1_ports.pg) + + if p1.db_root.exists(): + shutil.rmtree(p1.db_root) + obj_store.wipe() + + p2 = server_factory("p2", db_root_name="p2-fresh") + p2.start(http_port=p1_ports.http, pg_port=p1_ports.pg) + + wait_for_dense_sequence(port=p1_ports.pg, table=table, + expected_count=row_count, timeout_s=60.0) + + +@pytest.mark.net_client +def test_failover_during_active_send(server_factory, net_sidecar, + obj_store: ObjStore, scenario_dir: Path) -> None: + """Kill P1 while the sender is still pushing batches.""" + table = "net_trades_inflight" + sf_dir = scenario_dir / "sf" + batches = 5 + rows_per_batch = 20 + expected = batches * rows_per_batch + + p1 = server_factory("p1") + p1_ports = p1.start() + net_sidecar.connect(_connect_string(p1_ports.http, sf_dir)) + + net_sidecar.send(table, count=rows_per_batch, start_index=0) + net_sidecar.flush() + for i in range(1, batches): + net_sidecar.send(table, count=rows_per_batch, start_index=i * rows_per_batch) + + p1.kill_9() + wait_port_free(p1_ports.http) + wait_port_free(p1_ports.pg) + + if p1.db_root.exists(): + shutil.rmtree(p1.db_root) + obj_store.wipe() + + p2 = server_factory("p2", db_root_name="p2-fresh") + p2.start(http_port=p1_ports.http, pg_port=p1_ports.pg) + + net_sidecar.flush() + + wait_for_dense_sequence(port=p1_ports.pg, table=table, + expected_count=expected, timeout_s=60.0) + + +@pytest.mark.net_client +def test_two_failovers_in_one_scenario(server_factory, net_sidecar, + obj_store: ObjStore, scenario_dir: Path) -> None: + """Multiple failovers in a row — no row should be lost.""" + table = "net_trades_two_fail" + sf_dir = scenario_dir / "sf" + rows_per_phase = 25 + expected = rows_per_phase * 3 + + # Phase 1. + p1 = server_factory("p1") + p1_ports = p1.start() + net_sidecar.connect(_connect_string(p1_ports.http, sf_dir)) + net_sidecar.send(table, count=rows_per_phase, start_index=0) + net_sidecar.flush() + time.sleep(0.5) + p1.kill_9() + wait_port_free(p1_ports.http) + wait_port_free(p1_ports.pg) + if p1.db_root.exists(): + shutil.rmtree(p1.db_root) + obj_store.wipe() + + # Phase 2. + p2 = server_factory("p2", db_root_name="p2-fresh") + p2.start(http_port=p1_ports.http, pg_port=p1_ports.pg) + net_sidecar.send(table, count=rows_per_phase, start_index=rows_per_phase) + net_sidecar.flush() + time.sleep(0.5) + p2.kill_9() + wait_port_free(p1_ports.http) + wait_port_free(p1_ports.pg) + if p2.db_root.exists(): + shutil.rmtree(p2.db_root) + obj_store.wipe() + + # Phase 3. + p3 = server_factory("p3", db_root_name="p3-fresh") + p3.start(http_port=p1_ports.http, pg_port=p1_ports.pg) + net_sidecar.send(table, count=rows_per_phase, start_index=rows_per_phase * 2) + net_sidecar.flush() + + wait_for_dense_sequence(port=p1_ports.pg, table=table, + expected_count=expected, timeout_s=90.0) + + +@pytest.mark.net_client +def test_no_request_durable_ack_loses_rows(server_factory, net_sidecar, + obj_store: ObjStore, scenario_dir: Path) -> None: + """Without durable-ack opt-in, SF trims on OK — killing P1 loses rows. + This is the negative case that proves the harness is wired correctly.""" + table = "net_trades_no_durable" + sf_dir = scenario_dir / "sf" + row_count = 50 + + p1 = server_factory("p1") + p1_ports = p1.start() + + net_sidecar.connect(_connect_string(p1_ports.http, sf_dir, request_durable_ack=False)) + net_sidecar.send(table, count=row_count, start_index=0) + fsn = net_sidecar.flush() + net_sidecar.await_acked(fsn, timeout_ms=30_000) + + p1.kill_9() + wait_port_free(p1_ports.http) + wait_port_free(p1_ports.pg) + + if p1.db_root.exists(): + shutil.rmtree(p1.db_root) + obj_store.wipe() + + p2 = server_factory("p2", db_root_name="p2-fresh") + p2_ports = p2.start(http_port=p1_ports.http, pg_port=p1_ports.pg) + + time.sleep(5) + + from lib.pg_query import execute_ddl + import psycopg + try: + conn = psycopg.connect( + f"host=127.0.0.1 port={p2_ports.pg} user=admin password=quest dbname=qdb", + autocommit=True, + ) + cur = conn.execute(f"SELECT count() FROM '{table}'") + actual = cur.fetchone()[0] + conn.close() + except Exception: + actual = 0 + + assert actual < row_count, ( + f"Expected data loss without durable-ack but got {actual}/{row_count} rows" + )