diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..02c0ebb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,451 @@ +# 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 unit-test pass (excludes [Explicit] integration tests that need +# a live QuestDB / Docker). +dotnet test src/net-questdb-client-tests/net-questdb-client-tests.csproj \ + --framework net10.0 -c Release + +# 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 (`[Explicit]`) — boot a real QuestDB via Docker, +# require Docker daemon + the master image with /write/v4 + /read/v1 enabled. +QUESTDB_IMAGE=questdb/questdb:master \ + dotnet test src/net-questdb-client-tests/net-questdb-client-tests.csproj \ + --framework net10.0 -c Release \ + --filter "FullyQualifiedName~QuestDbWebSocketIntegrationTests" + +# Egress integration tests (read-side QWP). Uses `[Category("integration")]` +# rather than `[Explicit]` so the filter shape differs. Skip Docker by +# pointing QDB_LIVE_HTTP / QDB_LIVE_ILP at an existing master instance; +QDB_LIVE_HTTP=127.0.0.1:9000 QDB_LIVE_ILP=127.0.0.1:9009 \ + dotnet test src/net-questdb-client-tests/net-questdb-client-tests.csproj \ + --framework net10.0 -c Release \ + --filter "TestCategory=integration&FullyQualifiedName~QuestDbQueryIntegration" + +# 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, schema-mode bytes + (`SchemaModeFull` / `SchemaModeReference`). Self-sufficient framing is + a client-side mode (full schema + 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; chooses `SchemaModeFull` vs + `SchemaModeReference` per table based on the schema cache. +- `QwpSchemaCache.cs` / `QwpSymbolDictionary.cs` — schema id allocation + and delta symbol dictionary. Caches advance after a successful enqueue + (async mode) or a successful ACK (sync mode). Self-sufficient mode + (used by SF) bypasses both caches and re-emits the full schema + + full symbol dict on every frame. +- `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 < 2 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. +- `QwpInFlightWindow.cs` — bounded ACK-pending tracker for non-SF async + mode. `AwaitEmpty(timeout)` is the producer-side drain. +- `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 + (`in_flight_window=1` is rejected at construction; the engine's + double-buffered pumps assume window ≥ 2 — for one-batch-at-a-time + ILP semantics use the `http::` scheme instead): + - **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`; `IsHolderProcessAlive` is a PID-based liveness + check, with a `.heartbeat` mtime check (`IsHolderHeartbeatFresh`) + refreshed by the segment manager every ~1s — orphan scanner adopts + any slot whose heartbeat is older than 5 min, even if the recorded + PID happens to be live (PID-reuse safe). +- `QwpMmapSegment.cs` — single mmap'd segment file with envelope frames + `[u32 crc32c][u32 frame_len][frame bytes]`. Replays on open via + `ScanForLastGoodEnvelope` to find the last good write position; + truncates torn tails. On `Seal()` writes a 16-byte trailer + (magic + last-good-offset) at the file's end; `Open()` reads the + trailer first and skips per-envelope CRC verification on the + walk-and-build-offsets pass when the trailer is consistent (falls + back to a full CRC scan when the trailer is missing, corrupt, or + doesn't match the envelope walk). +- `QwpMemorySegment.cs` — RAM-backed segment via + `NativeMemory.Alloc` / `Free`. Same envelope wire format as mmap + (preserves CRC verification on read), but no on-disk header / + trailer — 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. +- `in_flight_window`, `close_timeout`, `max_schemas_per_connection`, + `gorilla`, `request_durable_ack`, `sf_*`, `reconnect_*`, + `initial_connect_retry`, `close_flush_timeout_millis`, `drain_orphans`, + `max_background_drainers`, `sender_id`: 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=0`, `auto_flush_interval=100ms`, + matching Java `DEFAULT_WS_AUTO_FLUSH_*`) only apply when + `auto_flush != off` — `auto_flush=off` is honoured even for ws. +- `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/QwpSchemaCacheTests.cs`, + `Qwp/QwpSymbolDictionaryTests.cs`, `Qwp/QwpInFlightWindowTests.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: + - `QuestDbIntegrationTests.cs` (`[Explicit]`) — HTTP/TCP integration + via `QuestDbManager` (Docker container provisioning). + - `QuestDbWebSocketIntegrationTests.cs` (`[Explicit]`) — ingest WS/QWP + integration. Requires `questdb/questdb:master`; gated by + `QUESTDB_IMAGE` env var. + - `QuestDbQueryIntegrationTests.cs` (`[Category("integration")]`) — + egress integration. Filter via `TestCategory=integration` rather + than `[Explicit]`. `OneTimeSetUp` drops + re-seeds fixture tables + so runs are idempotent against a long-lived master instance. + `QuestDbManager` honours `QDB_LIVE_HTTP` / `QDB_LIVE_ILP` to skip + Docker when an existing instance is already running. +- `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 bbbcba0..cdbb039 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,140 @@ 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 up to 128 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(); +``` + +`in_flight_window` controls the pipeline depth; valid range is `2..N`. The WebSocket transport is async-only — `in_flight_window=1` is rejected. + +#### 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 + +Set `gorilla=on` to enable delta-of-delta compression for timestamp columns. Best fit for steady-tick streams (sensor readings, evenly spaced ticks). The encoder transparently falls back to uncompressed per column when DoDs overflow int32: + +```csharp +using var sender = Sender.New("ws::addr=localhost:9000;gorilla=on;"); +``` + +For irregular timestamps (event-driven workloads) Gorilla can be larger than uncompressed; benchmark with your actual data before enabling. + +#### 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` | +| `in_flight_window` | 128 | n/a | +| `close_flush_timeout_millis` | 5000 ms | n/a | +| `max_schemas_per_connection` | 65535 | n/a | +| `request_durable_ack` | `off` | n/a | +| `gorilla` | `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 +281,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 +292,38 @@ 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 | +| --------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------- | +| `in_flight_window` | `128` | Max pipelined batches awaiting ACK. Minimum is `2` — `in_flight_window=1` is rejected. | +| `max_schemas_per_connection` | `65535` | Per-connection cap on distinct schema IDs. Hitting it requires recreating the sender. | +| `gorilla` | `off` | `on` / `off` — enables Gorilla DoD compression on timestamp columns. | +| `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` | `5000` | Max wait at `Dispose` for the SF engine to drain. `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: @@ -243,7 +396,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 5c894b7..22f9a88 100644 --- a/ci/azure-pipelines.yml +++ b/ci/azure-pipelines.yml @@ -81,23 +81,58 @@ steps: arguments: '--configuration $(buildConfiguration) --no-restore' - 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 120s --blame-hang-dump-type mini --filter "FullyQualifiedName!~QuestDbIntegrationTests"' + 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 120s --blame-hang-dump-type mini --collect:"XPlat Code Coverage"' publishTestResults: true 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 120s --blame-hang-dump-type mini --collect:"XPlat Code Coverage" --filter "FullyQualifiedName!~QuestDbIntegrationTests"' 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 120s --blame-hang-dump-type mini --filter "FullyQualifiedName!~QuestDbIntegrationTests"' + 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 0000000..e77758e --- /dev/null +++ b/docs/qwp-benchmarks.md @@ -0,0 +1,150 @@ +# .NET WebSocket / QWP — Performance Benchmarks + +## Environment + +- **Server**: QuestDB master build with `/write/v4` 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 by **3–6×** across narrow / wide / multi-table at `in_flight_window=128`. All throughput / alloc targets pass with margin. +- ✅ **Ingest latency** (`BenchLatencyWs`, sync mode): WS is faster than HTTP at every batch size. Single-row WS p95 = **170 μs** vs HTTP **237 μs**; 10k-row batch shows **3.85× advantage**. +- ✅ **Egress reads** (`BenchQueryWs`): WS reads **3.5–6.1×** faster than HTTP `/exec` from 10k–1M rows; peak **37 M rows/sec** on a 2-column 1M-row query. Per-batch decoder allocation cut **1170×** by column-scratch pooling. +- ✅ **SF overhead** (`BenchSfThroughput`): SF is **0.83–1.43×** non-SF at the same IFW. SF is faster than non-SF at IFW=1 (single-frame ACK wait masks the disk-append cost); flat at 1.36–1.43× for IFW≥8. Within the ≤ 1.45× target. + +## Methodology + +- All benches run in-process against a real QuestDB instance on local loopback. `QDB_BENCH_ENDPOINT=127.0.0.1:9000` selects the live server; without it the bench falls back to in-process `DummyQwpServer` + `DummyHttpServer`. +- Throughput / SF benches use a fast job (2 warmup + 3 iter, `InvocationCount=1`) — small sample, so error bars are wide. Use min / median / p95 for relative ordering rather than the BDN 99.9% CI. +- Latency bench uses 1000 iterations with `InvocationCount=1` (every iteration is one full round-trip), so percentile columns reflect 1000 samples. +- 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, send N rows, `SendAsync()`. Three row shapes (Narrow / Wide / MultiTable) with HTTP baselines and per-category Ratio columns. + +### Headline @ `InFlightWindow=128`, `Rows=100000` + +| Category | AFR | Mean WS | Mean HTTP | WS rows/sec | HTTP rows/sec | WS Ratio | Alloc WS / HTTP | +|---|---|---|---|---|---|---|---| +| **Narrow** | 1000 | 8.11 ms | 43.93 ms | 12.3 M | 2.28 M | **0.18 (5.4×)** | 13 / 19 KB (0.67×) | +| **Narrow** | 10000 | 8.37 ms | 33.87 ms | 11.9 M | 2.95 M | **0.25 (4.1×)** | 13 / 19 KB (0.68×) | +| **Wide** | 1000 | 28.08 ms | 123.24 ms | 3.56 M | 0.81 M | **0.23 (4.4×)** | 43 / 83 KB (0.52×) | +| **Wide** | 10000 | 28.40 ms | 116.58 ms | 3.52 M | 0.86 M | **0.24 (4.1×)** | 43 / 83 KB (0.52×) | +| **MultiTable** | 1000 | 5.73 ms | 36.04 ms | 17.5 M | 2.78 M | **0.16 (6.3×)** | 8.6 / 15 KB (0.57×) | +| **MultiTable** | 10000 | 5.70 ms | 22.45 ms | 17.6 M | 4.45 M | **0.25 (4.0×)** | 8.6 / 15 KB (0.58×) | + +### Observations + +- **WS dominates HTTP across every row shape and AutoFlushRows setting** — minimum advantage 4.0×, peak 6.3× (MultiTable @ AFR=1000). +- **Memory is uniformly lower for WS** — 0.52× to 0.68× HTTP allocations. +- **Wide rows ceiling around 3.5 M rows/sec** — payload-bound; WS still preserves a 4× ratio over HTTP because protocol overhead matters more for wide rows. +- **MultiTable peaks at 17.6 M rows/sec** — WS multiplexes 5 tables over a single connection without per-flush handshake cost. +- **AutoFlushRows trade-off**: AFR=1000 gives the biggest WS advantage (more flushes amplify HTTP per-request overhead). AFR=10000 narrows the gap but raises absolute throughput modestly. Production sweet spot is AFR=1000–10000 depending on latency tolerance. + +## 2. `BenchLatencyWs` — Round-trip latency, sync mode (`in_flight_window=1`) + +**Workload**: persistent sender, send `RowsPerBatch` rows, `SendAsync()` and await. 1000 iterations per case. + +| RowsPerBatch | Method | Median | p95 | p100 (max) | Min | Ratio | Allocated | +|---|---|---|---|---|---|---|---| +| **1** | Http_Roundtrip | 150 μs | 237 μs | 283 μs | 70 μs | 1.00 | 8920 B | +| **1** | **Ws_SyncRoundtrip** | **110 μs** | **170 μs** | **194 μs** | 63 μs | **0.78** | **0** | +| **100** | Http_Roundtrip | 134 μs | 199 μs | 221 μs | 76 μs | 1.00 | 21504 B | +| **100** | **Ws_SyncRoundtrip** | **103 μs** | **151 μs** | **175 μs** | 65 μs | **0.81** | **11608 B** | +| **10000** | Http_Roundtrip | 2.40 ms | 2.52 ms | 2.61 ms | 2.15 ms | 1.00 | 1.21 MB | +| **10000** | **Ws_SyncRoundtrip** | **610 μs** | **693 μs** | **736 μs** | 526 μs | **0.26** | **566 KB** | + +### Observations + +- **Single-row p95**: WS 170 μs vs HTTP 237 μs — 28% faster. +- **Zero-allocation single-row roundtrip on WS** — re-used encoder buffers + persistent connection. HTTP allocates 8.9 KB per single-row roundtrip. +- **10000-row batch**: **3.85× faster** (610 μs vs 2.40 ms) and 47% of HTTP allocation. This is the realistic batched-streaming case where WS pipelining inside the batch is decisive. +- **Variance is consistent** — Min/Median/p95/p100 all show the same WS-vs-HTTP ordering across all batch sizes. + +Run at `IterationCount=100_000` if you need a strict p99. + +## 3. `BenchQueryWs` — Egress (read) throughput + +**Workload**: persistent `QwpQueryClient`, `SELECT * FROM table LIMIT N` against pre-seeded tables (1 M rows each). HTTP `/exec` baseline parses the JSON `dataset[][]` and counts rows so both methods do equivalent extraction work. 20 iterations × 5 warmup; error margin < 2 % at the 1 M-row regime. + +| Category | RowCount | Mean WS | Mean HTTP | WS rows/sec | WS Ratio | +|---|---|---|---|---|---| +| **Narrow** (2 cols) | 10 k | 815 μs | 2.89 ms | 12.27 M | **0.28 (3.5×)** | +| **Narrow** | 100 k | 3.77 ms | 17.7 ms | 26.54 M | **0.21 (4.7×)** | +| **Narrow** | 1 M | 27.0 ms | 164 ms | **37.08 M** | **0.16 (6.1×)** | +| **Wide** (15 cols) | 10 k | 2.48 ms | 6.56 ms | 4.03 M | **0.38 (2.6×)** | +| **Wide** | 100 k | 16.9 ms | 59.3 ms | 5.91 M | **0.29 (3.5×)** | +| **Wide** | 1 M | 159 ms | 576 ms | 6.30 M | **0.28 (3.6×)** | + +- **3.5–6.1× faster than HTTP `/exec`**; peak **37 M rows/sec** at Narrow 1 M. +- **Decoder allocations dropped 1170×** after column-scratch pooling (Wide 100 k: 10 MB → 8.6 KB / batch). `ValueBytes` / `StringHeap` / `NonNullIndex` / `StringOffsets` survive `Reset` and grow-and-reuse across batches; only varchar/symbol heap deltas allocate. +- Wide rows cap at **~6 M rows/sec** — payload-bound, like the ingest side. + +## 4. `BenchSfThroughput` — Store-and-forward overhead + +**Workload**: long-lived senders (one with `sf_dir`, one without). Each iteration sends N rows, `SendAsync()`, then `Ping()` to wait for cumulative ACK — symmetric across both branches. + +| InFlightWindow | Rows | Mean WS_NoSf | Mean WS_WithSf | SF Ratio | Alloc Ratio | +|---|---|---|---|---|---| +| 1 | 10000 | 2.86 ms | 2.70 ms | **0.94** | 1.13 | +| 1 | 100000 | 36.94 ms | 28.46 ms | **0.83** | 1.13 | +| 8 | 10000 | 1.12 ms | 1.54 ms | **1.38** | 1.14 | +| 8 | 100000 | 10.29 ms | 13.85 ms | **1.36** | 1.14 | +| 32 | 10000 | 1.12 ms | 1.59 ms | **1.43** | 1.14 | +| 32 | 100000 | 9.80 ms | 13.06 ms | **1.36** | 1.14 | +| 128 | 10000 | 1.05 ms | 1.44 ms | **1.37** | 1.14 | +| 128 | 100000 | 8.99 ms | 12.41 ms | **1.38** | 1.14 | + +### Observations + +- **At IFW=1, SF is faster than non-SF** (0.83–0.94×). Both branches block per-frame on ACK, so the SF mmap append hides under the round-trip wait. +- **At IFW≥8 the ratio flattens to 1.36–1.43**, regardless of IFW or Rows. Constant per-frame architectural cost — disk append + cursor-engine signaling + segment-ring bookkeeping. Does not scale with IFW: cursor engine pumps don't serialize on the in-flight window. +- **Allocation overhead is uniform 1.13–1.14×** non-SF — segment-ring envelopes amortize once the sender is long-lived; only steady-state per-frame structures churn. + +SF's flat 1.36–1.43× tax at IFW≥8 is the per-frame architectural cost (disk append + cursor-engine signaling + segment-ring bookkeeping). At IFW=128 with long-lived senders that buys crash safety for ~10pp on the throughput side. + +## Caveats + +1. **N=3 iterations** for InsertsWs / SfThroughput → 99.9% CIs are wider than means. Use min / median / p95 for relative ordering; gate verdicts use min / median for conservatism. +2. **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. +3. **Single-host** — server and client share CPU and memory; cross-process cache contention may slightly inflate latency. The 14-core M4 Pro keeps contention minimal. +4. **SF bench is happy-path ingest only** — the product justification for SF (reconnect + replay through server outage) is not exercised here. A transient-failure benchmark is separately needed. + +## Acceptance summary + +| Pillar | Status | +|---|---| +| WS narrow ingest throughput ≥ 1.5× HTTP @ IFW=128 | ✅ 4.05× — 5.42× (margin: 2.7–3.6×) | +| WS wide ingest throughput ≥ 1.2× HTTP @ IFW=128 | ✅ 4.10× — 4.39× (margin: 3.4–3.7×) | +| WS sync single-row p100 ≤ 1.5× HTTP | ✅ 0.69× (WS faster than HTTP) | +| WS async 10000-row p100 ≤ HTTP 10000-row p100 | ✅ 0.28× (WS 3.6× faster) | +| **WS egress reads ≥ HTTP `/exec` baseline** | ✅ 2.6× — 6.1× across narrow / wide × 10k / 100k / 1M | +| **SF overhead ≤ 45%** | ✅ 0.83–1.43× (passes at every IFW; flat curve at IFW≥8) | +| GC alloc / 1k rows ≤ 2× HTTP | ✅ 0.52× — 0.68× HTTP across all shapes | + +## 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*' + +# Latency, strict (100k samples for RowsPerBatch=1) +QDB_BENCH_ENDPOINT=127.0.0.1:9000 \ + dotnet run -c Release --project src/net-questdb-client-benchmarks --framework net10.0 -- \ + --filter '*BenchLatencyWs*RowsPerBatch:*1*' + +# Latency quick all-batches +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 --warmupCount 5 +``` diff --git a/examples.manifest.yaml b/examples.manifest.yaml index 7351a00..27b1a3b 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 91cec48..4a26c3a 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}") = "src", "src", "{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 0000000..d31db68 --- /dev/null +++ b/src/dummy-http-server/DummyQwpServer.cs @@ -0,0 +1,316 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; } + + /// 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); + + // 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; + } + await ctx.Response.WriteAsync("rejected by test").ConfigureAwait(false); + return; + } + + if (_options.RoleHeader is { Length: > 0 } acceptRole) + { + ctx.Response.Headers["X-QuestDB-Role"] = acceptRole; + } + + using var ws = await ctx.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); + + if (_options.InitialServerFrame is { Length: > 0 } initial) + { + await ws.SendAsync(initial, WebSocketMessageType.Binary, endOfMessage: true, 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 ws.SendAsync(response, WebSocketMessageType.Binary, endOfMessage: true, ctx.RequestAborted) + .ConfigureAwait(false); + } + } + } + else + { + var response = _options.FrameHandler?.Invoke(frame); + if (response is not null && response.Length > 0) + { + await ws.SendAsync(response, WebSocketMessageType.Binary, endOfMessage: true, ctx.RequestAborted) + .ConfigureAwait(false); + } + } + + framesHandled++; + if (_options.CloseAfterFrameCount is { } cap && framesHandled >= cap) + { + await ws.CloseAsync(_options.CloseStatus, _options.CloseReason, ctx.RequestAborted) + .ConfigureAwait(false); + return; + } + } + } +} + +/// 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 (used with 503 to test role-aware failover). + public string? RejectUpgradeRoleHeader { get; init; } + + /// Optional X-QuestDB-Role header value attached to a successful 101 response (diagnostic / tests). + public string? RoleHeader { get; init; } + + /// If set, Kestrel binds an HTTPS listener using this certificate; returns a wss:// URI. + public X509Certificate2? TlsCertificate { 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; +} 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 0000000..c2a47fa --- /dev/null +++ b/src/example-qwp-ingest-auth-tls/Program.cs @@ -0,0 +1,47 @@ +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 0000000..91fe788 --- /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 0000000..60ef746 --- /dev/null +++ b/src/example-qwp-ingest/Program.cs @@ -0,0 +1,40 @@ +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 0000000..1931d0f --- /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 0000000..8fbac01 --- /dev/null +++ b/src/example-qwp-query/Program.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. + * + ******************************************************************************/ + +using QuestDB; +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(byte status, string message) => + Console.Error.WriteLine($"-- error 0x{status:X2}: {message} --"); + public override void OnExecDone(short 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 0000000..2611b5e --- /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 0000000..34062e0 --- /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};in_flight_window=128;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 0000000..fc6c82e --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchInsertsWs.cs @@ -0,0 +1,201 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.Configs; +using BenchmarkDotNet.Order; +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. +/// +[MemoryDiagnoser] +[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; + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchLatencyWs.cs b/src/net-questdb-client-benchmarks/BenchLatencyWs.cs new file mode 100644 index 0000000..12683c7 --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchLatencyWs.cs @@ -0,0 +1,174 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using QuestDB; +using QuestDB.Senders; +using dummy_http_server; + +namespace net_questdb_client_benchmarks; + +/// +/// Single-batch round-trip latency at the smallest valid pipeline depth (in_flight_window=2; +/// =1 is rejected by the WS sender). Each iteration is one full RTT (send + ack) — +/// IterationCount = sample size for p50/p95/min/max. Override with --iterationCount N +/// on the CLI when you need fewer/more samples. +/// +[MemoryDiagnoser] +[Config(typeof(LatencySamplingConfig))] +public class BenchLatencyWs +{ + private DummyQwpServer? _qwpServer; + private DummyHttpServer? _httpServer; + private string _httpEndpoint = null!; + private string _wsEndpoint = null!; + private ISender _wsSender = null!; + private ISender _httpSender = null!; + private long _rowSeq; + + [Params(1, 100, 10_000)] + 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}"; + } + + _wsSender = Sender.New($"ws::addr={_wsEndpoint};in_flight_window=2;auto_flush=off;"); + _httpSender = Sender.New($"http::addr={_httpEndpoint};auto_flush=off;"); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _wsSender?.Dispose(); } catch { } + try { _httpSender?.Dispose(); } catch { } + if (_qwpServer is not null) await _qwpServer.DisposeAsync(); + _httpServer?.Dispose(); + } + + [Benchmark(Baseline = true)] + public async Task Http_Roundtrip() + { + for (var i = 0; i < RowsPerBatch; i++) + { + _httpSender.Table("lat") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(DateTime.UtcNow); + } + + await _httpSender.SendAsync(); + } + + [Benchmark] + public async Task Ws_SyncRoundtrip() + { + for (var i = 0; i < RowsPerBatch; i++) + { + _wsSender.Table("lat") + .Column("v", Interlocked.Increment(ref _rowSeq)) + .At(DateTime.UtcNow); + } + + await _wsSender.SendAsync(); + } + + 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 / p99 / min / max. Defaults to 100k +/// for stable p99; override with --iterationCount N on the CLI for quick local runs. +/// +public class LatencySamplingConfig : ManualConfig +{ + public LatencySamplingConfig() + { + AddJob(Job.Default + .WithLaunchCount(1) + .WithWarmupCount(5) + .WithIterationCount(100_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); + // BDN 0.13 has no named P99 column. The CSV report contains every raw iteration time; + // post-process the *-report.csv if a strict p99 figure is needed. + } +} + +#endif diff --git a/src/net-questdb-client-benchmarks/BenchQueryWs.cs b/src/net-questdb-client-benchmarks/BenchQueryWs.cs new file mode 100644 index 0000000..92361c3 --- /dev/null +++ b/src/net-questdb-client-benchmarks/BenchQueryWs.cs @@ -0,0 +1,406 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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 walks every row; HTTP parses the JSON response and +/// counts dataset rows so the comparison is apples-to-apples on extraction work, not just I/O. +/// +[MemoryDiagnoser] +[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 int SeedRows = 1_000_000; + + private string _endpoint = null!; + private HttpClient _http = null!; + private IQwpQueryClient _ws = null!; + private CountingHandler _handler = null!; + + [Params(10_000, 100_000, 1_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) }; + _ws = QueryClient.New($"ws::addr={_endpoint};"); + _handler = new CountingHandler(); + + await DropAsync(NarrowTable); + await DropAsync(WideTable); + await SeedNarrowAsync(SeedRows); + await SeedWideAsync(SeedRows); + await WaitForRowsAsync(NarrowTable, SeedRows); + await WaitForRowsAsync(WideTable, SeedRows); + } + + [GlobalCleanup] + public async Task Cleanup() + { + try { _ws?.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 HttpSelectCountRowsAsync(NarrowTable); + + [Benchmark, BenchmarkCategory("Narrow")] + public int Ws_NarrowSelect() => WsSelectCountRows(NarrowTable); + + [Benchmark(Baseline = true), BenchmarkCategory("Wide")] + public async Task Http_WideSelect() => await HttpSelectCountRowsAsync(WideTable); + + [Benchmark, BenchmarkCategory("Wide")] + public int Ws_WideSelect() => WsSelectCountRows(WideTable); + + private int WsSelectCountRows(string table) + { + _handler.Reset(); + _ws.Execute($"SELECT * FROM {table} LIMIT {RowCount}", _handler); + return _handler.TotalRows; + } + + private async Task HttpSelectCountRowsAsync(string table) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString($"SELECT * 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 CountRowsStreamingAsync(stream); + } + + 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) + .Symbol("region", i % 2 == 0 ? "us" : "eu") + .Column("price", i * 1.5) + .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) + .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", "string-" + i) + .Column("h", now) + .Column("i", (long)(i + 1)).Column("j", i * 1.25) + .Column("k", "k-" + i).Column("l", i % 3 == 0) + .Column("m", (long)(i * 2)).Column("n", (i % 7) * 0.7) + .At(now); + } + await sender.SendAsync(); + } + + private async Task DropAsync(string table) + { + var url = $"http://{_endpoint}/exec?query={Uri.EscapeDataString($"DROP TABLE IF EXISTS {table}")}"; + 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 CountRowsStreamingAsync(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. + var buffer = new byte[64 * 1024]; + var filled = 0; + long count = 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, 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; + } + + private static int ProcessChunk( + ReadOnlySpan span, bool isFinal, ref JsonReaderState state, + ref bool sawDataset, ref int depth, ref long count, 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; + } + } + + 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()); + } +} + +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 0000000..0d9aeda --- /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 0000000..45db980 --- /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. +/// +[MemoryDiagnoser] +public class BenchSfThroughput +{ + private DummyQwpServer? _qwpServer; + private string _wsEndpoint = null!; + private string _sfRoot = null!; + private ISender _wsNoSf = null!; + private ISender _wsWithSf = null!; + + [Params(10_000, 100_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 2b8b965..52d1afb 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,34 @@ 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); + } +#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 df9ec77..c0ae018 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,16 @@ public void DecimalNegationBoundaryCarry() }); } + [Test] + public void At_LocalDateTimeKind_NormalisedToUtc() + { + var local = DateTime.SpecifyKind(new DateTime(1970, 1, 1, 0, 0, 1), DateTimeKind.Local); + var bufferLocal = new BufferV3(128, 128, 128); + bufferLocal.Table("t").Column("x", 1).At(local); + + var bufferUtc = new BufferV3(128, 128, 128); + bufferUtc.Table("t").Column("x", 1).At(local.ToUniversalTime()); + + Assert.That(bufferLocal.GetSendBuffer().ToArray(), Is.EqualTo(bufferUtc.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 3218fb1..9c53d74 100644 --- a/src/net-questdb-client-tests/HttpTests.cs +++ b/src/net-questdb-client-tests/HttpTests.cs @@ -1406,8 +1406,8 @@ 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 DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))) + await sender.Column("foo", new DateTime(1970, 1, 1)).AtAsync(86400000000000); + await sender.Column("foo", new DateTimeOffset(new DateTime(1970, 1, 1))) .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 84dd00d..749f30e 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)); @@ -320,42 +319,6 @@ public void AddressProvider_ParseHostAndPort() var provider2 = new AddressProvider(new[] { "example.com:8080", }); 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)); - } - - [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)); } [Test] diff --git a/src/net-questdb-client-tests/QuestDbManager.cs b/src/net-questdb-client-tests/QuestDbManager.cs index e00c939..649aad6 100644 --- a/src/net-questdb-client-tests/QuestDbManager.cs +++ b/src/net-questdb-client-tests/QuestDbManager.cs @@ -8,13 +8,16 @@ namespace net_questdb_client_tests; /// public class QuestDbManager : IAsyncDisposable { - private const string DockerImage = "questdb/questdb:latest"; + private const string DefaultImage = "questdb/questdb:latest"; private const string ContainerNamePrefix = "questdb-test-"; + private readonly string _dockerImage; private readonly string _containerName; private readonly HttpClient _httpClient; private readonly int _httpPort; private readonly int _port; + private readonly string? _liveHttp; + private readonly string? _liveIlp; private string? _containerId; private string? _volumeName; @@ -23,14 +26,32 @@ public class QuestDbManager : IAsyncDisposable /// /// ILP port (default: 9009) /// HTTP port (default: 9000) - public QuestDbManager(int port = 9009, int httpPort = 9000) + /// + /// Override the image; falls back to the QUESTDB_IMAGE env var, then + /// questdb/questdb:latest. + /// + public QuestDbManager(int port = 9009, int httpPort = 9000, string? dockerImage = null) { _port = port; _httpPort = httpPort; + _liveHttp = NormalizeEndpoint(Environment.GetEnvironmentVariable("QDB_LIVE_HTTP")); + _liveIlp = NormalizeEndpoint(Environment.GetEnvironmentVariable("QDB_LIVE_ILP")); + var candidate = string.IsNullOrWhiteSpace(dockerImage) + ? Environment.GetEnvironmentVariable("QUESTDB_IMAGE") + : dockerImage; + _dockerImage = string.IsNullOrWhiteSpace(candidate) ? DefaultImage : candidate.Trim(); _containerName = $"{ContainerNamePrefix}{port}-{httpPort}-{Guid.NewGuid().ToString().Substring(0, 8)}"; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5), }; } + private bool UseLiveServer => !string.IsNullOrEmpty(_liveHttp); + + private static string? NormalizeEndpoint(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + return raw.Trim(); + } + public bool IsRunning { get; private set; } /// @@ -89,12 +110,12 @@ public async Task PullImageAsync() // Check if image already exists locally if (await ImageExistsAsync()) { - Console.WriteLine($"Docker image already exists locally: {DockerImage}"); + Console.WriteLine($"Docker image already exists locally: {_dockerImage}"); return; } - Console.WriteLine($"Pulling Docker image {DockerImage}..."); - var (exitCode, output) = await RunDockerCommandAsync($"pull {DockerImage}"); + Console.WriteLine($"Pulling Docker image {_dockerImage}..."); + var (exitCode, output) = await RunDockerCommandAsync($"pull {_dockerImage}"); if (exitCode != 0) { throw new InvalidOperationException($"Failed to pull Docker image: {output}"); @@ -110,7 +131,7 @@ 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"); + var (exitCode, output) = await RunDockerCommandAsync($"images --filter \"reference={_dockerImage}\" --quiet"); // If the image exists, output will contain the image ID // If it doesn't exist, output will be empty @@ -128,6 +149,14 @@ public async Task StartAsync() return; } + if (UseLiveServer) + { + Console.WriteLine($"Using live QuestDB at {_liveHttp} (skipping Docker)"); + await WaitForQuestDbAsync(); + IsRunning = true; + return; + } + await EnsureDockerAvailableAsync(); // Clean up any existing containers using these ports @@ -152,7 +181,7 @@ public async Task StartAsync() $"-p {_port}:9009 " + $"--name {_containerName} " + volumeArg + - DockerImage; + _dockerImage; var (exitCode, output) = await RunDockerCommandAsync(runArgs); if (exitCode != 0) @@ -174,6 +203,12 @@ public async Task StartAsync() /// public async Task StopAsync() { + if (UseLiveServer) + { + IsRunning = false; + return; + } + if (!IsRunning || string.IsNullOrEmpty(_containerId)) { return; @@ -200,7 +235,7 @@ public async Task StopAsync() /// public string GetHttpEndpoint() { - return $"localhost:{_httpPort}"; + return _liveHttp ?? $"localhost:{_httpPort}"; } /// @@ -208,7 +243,13 @@ public string GetHttpEndpoint() /// public string GetIlpEndpoint() { - return $"localhost:{_port}"; + return _liveIlp ?? $"localhost:{_port}"; + } + + /// Gets the WebSocket (QWP) endpoint for QuestDB. Shares the HTTP port. + public string GetWebSocketEndpoint() + { + return _liveHttp ?? $"localhost:{_httpPort}"; } /// diff --git a/src/net-questdb-client-tests/QuestDbQueryIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbQueryIntegrationTests.cs new file mode 100644 index 0000000..fe79d31 --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbQueryIntegrationTests.cs @@ -0,0 +1,196 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; +using QuestDB.Enums; +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] +[Category("integration")] +[Explicit("Requires a QuestDB image with the /read/v1 egress endpoint — set QUESTDB_IMAGE to such a build")] +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.GetLongValue(0, 0), Is.EqualTo(42L)); + } + + [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); + } + + 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 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; + 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(short opType, long rowsAffected) => ExecDoneObserved = true; + public override void OnError(byte status, string message) => LastErrorStatus = status; + + public long[] AllLongs(int colIndex) + { + return _columnLongs.SelectMany(x => x).ToArray(); + } + } +} + +#endif diff --git a/src/net-questdb-client-tests/QuestDbWebSocketIntegrationTests.cs b/src/net-questdb-client-tests/QuestDbWebSocketIntegrationTests.cs new file mode 100644 index 0000000..3ee546c --- /dev/null +++ b/src/net-questdb-client-tests/QuestDbWebSocketIntegrationTests.cs @@ -0,0 +1,209 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +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] +[Explicit("Requires QuestDB master image (questdb/questdb:master) — sets QUESTDB_IMAGE env var")] +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(); + using var sender = Sender.New( + $"ws::addr={endpoint};auto_flush=off;request_durable_ack=on;"); + + sender.Table("test_ws_durable") + .Column("v", 42L) + .At(DateTime.UtcNow); + await sender.SendAsync(); + + var ws = (QuestDB.Senders.IQwpWebSocketSender)sender; + ws.Ping(); + + var durableSeqTxn = ws.GetHighestDurableSeqTxn("test_ws_durable"); + Assert.That(durableSeqTxn, Is.GreaterThanOrEqualTo(0L)); + } + + 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) + { + var value = await GetTableRowCountAsync(tableName, minimum: expected); + Assert.That(value, Is.GreaterThanOrEqualTo(expected)); + } + + private async Task GetTableRowCountAsync(string tableName, long minimum) + { + var endpoint = _questDb!.GetHttpEndpoint(); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + + var attempts = 0; + const int maxAttempts = 30; + + 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 0000000..add533f --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QueryOptionsTests.cs @@ -0,0 +1,439 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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(3)); + 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_NotAcceptedAsConnectStringKey() + { + Assert.Throws( + () => new QueryOptions("ws::addr=h:9000;initial_credit=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_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("auth=Bearer abc;username=u;password=p;")] + [TestCase("auth=X;token=t;")] + [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_RawAuthAlone_Accepted() + { + var o = new QueryOptions("ws::addr=h:9000;auth=Bearer xyz;"); + Assert.That(o.auth, Is.EqualTo("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;")); + } + + [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_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_AuthWithControlChar_Rejected() + { + Assert.Throws(() => new QueryOptions( + "ws::addr=h:9000;auth=Bearer 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 { auth = "Bearer x", token = "t" }; + Assert.Throws(() => o.EnsureValid()); + } +} 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 0000000..8f736ea --- /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 0000000..6d1076e --- /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/QwpEgressFrameBuilder.cs b/src/net-questdb-client-tests/Qwp/Query/QwpEgressFrameBuilder.cs new file mode 100644 index 0000000..05ca90e --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpEgressFrameBuilder.cs @@ -0,0 +1,465 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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); + + 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(byte role, ulong epoch, uint capabilities, long serverWallNs, + string clusterId, string nodeId) + { + var clusterBytes = Encoding.UTF8.GetBytes(clusterId); + var nodeBytes = Encoding.UTF8.GetBytes(nodeId); + var payload = new MemoryStream(); + payload.WriteByte(QwpConstants.MsgKindServerInfo); + payload.WriteByte(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); + 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.SupportedEgressVersion; + 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) + { + WriteVarint(payload, 0); // empty table name + WriteVarint(payload, (ulong)data.RowCount); + WriteVarint(payload, (ulong)schema.Columns.Count); + + if (schema.Mode == QwpConstants.SchemaModeFull) + { + payload.WriteByte(QwpConstants.SchemaModeFull); + WriteVarint(payload, (ulong)schema.SchemaId); + 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); + } + } + else + { + payload.WriteByte(QwpConstants.SchemaModeReference); + WriteVarint(payload, (ulong)schema.SchemaId); + } + + 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 byte Mode { get; init; } = QwpConstants.SchemaModeFull; + public long SchemaId { get; init; } + 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); + } + } + } +} 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 0000000..473d023 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpQueryClientEndToEndTests.cs @@ -0,0 +1,1538 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 + { + SchemaId = 1, + 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((short)7)); + Assert.That(handler.LastExecRowsAffected, Is.EqualTo(99L)); + } + + [Test] + public async Task V2ServerInfo_IsConsumedAtConnect_AndExposedViaServerInfo() + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpConstants.RolePrimary, + epoch: 7UL, + capabilities: 0, + serverWallNs: 1_700_000_000_000_000_000L, + clusterId: "qdb-prod", + nodeId: "node-1"); + + var schema = new ResultSchema { SchemaId = 1, 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 = "2", + 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(QwpConstants.RolePrimary)); + 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 V1Server_TargetPrimary_RejectedWithRoleMismatch() + { + // v1 server has no SERVER_INFO; target=primary cannot be honoured. + 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=primary;"))); + Assert.That(ex!.Target, Is.EqualTo(TargetType.primary)); + Assert.That(ex.LastObserved, Is.Null); + } + + [Test] + public async Task V1Server_TargetAny_AcceptsConnection() + { + var batch = QwpEgressFrameBuilder.BuildResultBatch( + 1L, 0L, + new ResultSchema { SchemaId = 1, 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.Null); + client.Execute("SELECT 1", new RecordingHandler()); + } + + [Test] + public async Task V2ServerInfo_RoleMismatch_ThrowsAndCarriesLastObserved() + { + var serverInfo = QwpEgressFrameBuilder.BuildServerInfo( + role: QwpConstants.RoleReplica, epoch: 1UL, capabilities: 0, serverWallNs: 0L, + clusterId: "c", nodeId: "n"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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(QwpConstants.RoleReplica)); + } + + [Test] + public async Task BindParameters_AreEmittedInQueryRequestFrame() + { + var schema = new ResultSchema { SchemaId = 1, 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.SupportedEgressVersion.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 { SchemaId = 1, 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 { SchemaId = 1, 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 { SchemaId = 1, 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 = System.Net.HttpStatusCode.Unauthorized, + }); + await server.StartAsync(); + + var ex = Assert.Throws(() => QueryClient.New(BuildConnString(server))); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.AuthError)); + } + + [Test] + public async Task FirstRequestId_IsOne() + { + var schema = new ResultSchema { SchemaId = 1, 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 { SchemaId = 1, 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 { SchemaId = 1, 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 { SchemaId = 1, 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_ThrowsProtocolVersionError() + { + 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)); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + StringAssert.Contains("magic", ex.Message); + } + + [Test] + public async Task SliceFrame_PayloadLengthMismatch_ThrowsProtocolVersionError() + { + 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)); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + } + + [Test] + public async Task UnknownMsgKind_ThrowsProtocolVersionError() + { + 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)); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + StringAssert.Contains("unknown egress frame", ex.Message); + } + + [Test] + public async Task ZstdCompressedBatch_DecodesCorrectly() + { + var schema = new ResultSchema + { + SchemaId = 1, + 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 { SchemaId = 1, Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(7L) } } }; + + var serverAInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + var serverBInfo = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: 2, capabilities: 0, serverWallNs: 0, "c", "node-b"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + InitialServerFrame = serverAInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await serverA.StartAsync(); + + var batchB = QwpEgressFrameBuilder.BuildResultBatch(2L, 0L, schema, data); + var endB = QwpEgressFrameBuilder.BuildResultEnd(2L, 0L, 1L); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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;lb_strategy=first;" + + "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 { SchemaId = 1, 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")); + } + + [Test] + public async Task CacheReset_SchemaBitClearsRegistry_NextReferenceModeBatchFails() + { + var schema = new ResultSchema { SchemaId = 9, Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data1 = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(1L) } } }; + var batchFull = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data1); + var end1 = QwpEgressFrameBuilder.BuildResultEnd(1L, 0L, 1L); + + var refSchema = new ResultSchema { Mode = QwpConstants.SchemaModeReference, SchemaId = 9, Columns = schema.Columns }; + var batchRef = QwpEgressFrameBuilder.BuildResultBatch(2L, 0L, refSchema, + new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(2L) } } }); + var resetSchemas = QwpEgressFrameBuilder.BuildCacheReset(QwpConstants.ResetMaskSchemas); + + var queryCount = 0; + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "1", + FrameHandlerMulti = frame => + { + if (frame[0] != QwpConstants.MsgKindQueryRequest) return null; + queryCount++; + return queryCount == 1 + ? new[] { batchFull, end1 } + : new[] { resetSchemas, batchRef }; + }, + }); + await server.StartAsync(); + + using var client = QueryClient.New(BuildConnString(server)); + var handler1 = new RecordingHandler(); + client.Execute("SELECT 1", handler1); + Assert.That(handler1.Ended, Is.True); + + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + } + + [Test] + public async Task ExecuteReentrancy_ThrowsInvalidApiCall() + { + var schema = new ResultSchema { SchemaId = 1, 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 = System.Net.HttpStatusCode.BadGateway, + }); + await serverA.StartAsync(); + + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = System.Net.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( + QwpConstants.RolePrimary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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 = System.Net.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;lb_strategy=first;" + + "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.AuthError)); + } + + [Test] + public async Task Failover_RotatesAcross3Endpoints_ThirdSucceeds() + { + var schema = new ResultSchema { SchemaId = 1, Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongLe(99L) } } }; + var infoC = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, 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 = System.Net.HttpStatusCode.BadGateway, + }); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + RejectUpgradeWith = System.Net.HttpStatusCode.ServiceUnavailable, + }); + await using var serverC = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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;" + + "lb_strategy=first;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( + QwpConstants.RolePrimary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "n"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + InitialServerFrame = serverInfo, + CloseAfterFrameCount = 1, + CloseStatus = System.Net.WebSockets.WebSocketCloseStatus.InternalServerError, + FrameHandler = _ => null, + }); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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( + QwpConstants.RoleReplica, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "replica"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + InitialServerFrame = info, + }); + await using var serverB = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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(QwpConstants.RoleReplica)); + } + + [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( + QwpConstants.RolePrimary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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 { SchemaId = 1, Columns = { new SchemaColumn("c", QwpTypeCode.Long) } }; + var infoA = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-a"); + var infoB = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: 2, capabilities: 0, serverWallNs: 0, "c", "node-b"); + + await using var serverA = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + 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 = "2", + 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;lb_strategy=first;" + + "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;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + 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;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + 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;")); + var ex = Assert.Throws(() => client.Execute("SELECT 1", new RecordingHandler())); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + 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;")); + // Corrupt zstd payload either fails the size pre-check (ProtocolVersionError) or fails Unwrap + // (ZstdSharp.ZstdException). Either is acceptable — both leave the connection terminal. + Assert.Catch(() => client.Execute("SELECT 1", new RecordingHandler())); + } + + [Test] + public async Task MixedRawAndZstdBatches_InSameQuery_BothDecode() + { + var schema = new ResultSchema { SchemaId = 1, 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 { Mode = QwpConstants.SchemaModeReference, SchemaId = 1, 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); + } + + [Test] + public async Task LbStrategy_Random_DistributesAcrossEndpoints() + { + var schema = new ResultSchema { SchemaId = 1, 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 servers = new DummyQwpServer[4]; + for (var i = 0; i < servers.Length; i++) + { + var info = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: (ulong)(i + 1), capabilities: 0, serverWallNs: 0, + clusterId: "c", nodeId: $"node-{i}"); + servers[i] = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, + NegotiatedVersion = "2", + InitialServerFrame = info, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await servers[i].StartAsync(); + } + try + { + var addrCsv = string.Join(",", servers.Select(s => s.Uri.Authority)); + var connBase = $"ws::addr={addrCsv};path={QwpConstants.ReadPath};target=primary;failover=off;"; + + var picked = new HashSet(); + const int trials = 50; + for (var t = 0; t < trials; t++) + { + using var client = QueryClient.New(connBase); + client.Execute("SELECT 1", new RecordingHandler()); + Assert.That(client.ServerInfo, Is.Not.Null); + picked.Add(client.ServerInfo!.NodeId); + } + Assert.That(picked.Count, Is.GreaterThan(1), + $"random LB should hit >1 distinct node across {trials} trials, got: [{string.Join(",", picked)}]"); + } + finally + { + foreach (var s in servers) await s.DisposeAsync(); + } + } + + [Test] + public async Task LbStrategy_First_AlwaysPicksAddressZero() + { + var info0 = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: 1, capabilities: 0, serverWallNs: 0, "c", "node-0"); + var info1 = QwpEgressFrameBuilder.BuildServerInfo( + QwpConstants.RolePrimary, epoch: 2, capabilities: 0, serverWallNs: 0, "c", "node-1"); + + var schema = new ResultSchema { SchemaId = 1, Columns = { new SchemaColumn("n", 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 s0 = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, NegotiatedVersion = "2", InitialServerFrame = info0, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await using var s1 = new DummyQwpServer(new DummyQwpServerOptions + { + Path = QwpConstants.ReadPath, NegotiatedVersion = "2", InitialServerFrame = info1, + FrameHandlerMulti = _ => new[] { batch, end }, + }); + await s0.StartAsync(); + await s1.StartAsync(); + + var conn = $"ws::addr={s0.Uri.Authority},{s1.Uri.Authority};path={QwpConstants.ReadPath};" + + "target=primary;lb_strategy=first;"; + for (var t = 0; t < 5; t++) + { + using var client = QueryClient.New(conn); + Assert.That(client.ServerInfo!.NodeId, Is.EqualTo("node-0")); + } + } + + 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; + } + + 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 short LastExecOpType { get; private set; } + public long LastExecRowsAffected { get; private set; } + public List FailoverResets { get; } = new(); + public Action? OnBatchHook { 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; + } + + public override void OnError(byte status, string message) + { + LastErrorStatus = status; + LastErrorMessage = message; + } + + public override void OnExecDone(short opType, long rowsAffected) + { + LastExecOpType = opType; + LastExecRowsAffected = rowsAffected; + } + + public override void OnFailoverReset(QwpServerInfo? newNode) + { + FailoverResets.Add(newNode); + } + } +} + +#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 0000000..3fe469e --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpResultBatchDecoderTests.cs @@ -0,0 +1,1266 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 + { + SchemaId = 7, + 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 + { + SchemaId = 1, + 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 + { + SchemaId = 2, + 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 + { + SchemaId = 3, + 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 + { + SchemaId = 4, + 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_ReferenceMode_ReusesPriorSchema() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + var schemaFull = new ResultSchema + { + Mode = QwpConstants.SchemaModeFull, + SchemaId = 11, + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var dataA = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(7L) } } }; + var frameA = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schemaFull, dataA); + + var batchA = new QwpColumnBatch(); + decoder.Decode(PayloadOf(frameA).Span, HeaderFlagsOf(frameA), batchA); + Assert.That(batchA.GetLongValue(0, 0), Is.EqualTo(7L)); + + var schemaRef = new ResultSchema + { + Mode = QwpConstants.SchemaModeReference, + SchemaId = 11, + Columns = schemaFull.Columns, + }; + var dataB = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(42L) } } }; + var frameB = QwpEgressFrameBuilder.BuildResultBatch(1L, 1L, schemaRef, 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_UnknownSchemaIdInReferenceMode_Throws() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + var schemaRef = new ResultSchema + { + Mode = QwpConstants.SchemaModeReference, + SchemaId = 999, + Columns = { new SchemaColumn("c", QwpTypeCode.Long) }, + }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(1L) } } }; + var frame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schemaRef, data); + + Assert.Throws(() => decoder.Decode(PayloadOf(frame).Span, HeaderFlagsOf(frame), new QwpColumnBatch())); + } + + [Test] + public void Decode_TruncatedPayload_Throws() + { + var schema = new ResultSchema + { + SchemaId = 5, + 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 + { + SchemaId = 6, + 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.GetLongValue(0, 0), Is.EqualTo(12345L)); + } + + [Test] + public void Decode_DoubleArrayColumn_RoundTripsRowMajor() + { + var schema = new ResultSchema + { + SchemaId = 9, + 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 + { + SchemaId = 10, + 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 Decode_GeohashColumn_RoundTripsPrecisionAndValue() + { + var schema = new ResultSchema + { + SchemaId = 8, + 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 + { + SchemaId = 81, + 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 + { + SchemaId = 41, + 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 + { + Mode = QwpConstants.SchemaModeReference, + SchemaId = 41, + 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 + { + SchemaId = 11, + 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 + { + SchemaId = 12, + 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 + { + SchemaId = 21, + 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 + { + SchemaId = 13, + 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 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 + p.Add(QwpConstants.SchemaModeFull); + p.Add(0x00); // schema_id + // 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(QwpConstants.SchemaModeFull); + p.Add(0x00); // schema_id + 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(QwpConstants.SchemaModeFull); + p.Add(0x00); // schema_id + 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(QwpConstants.SchemaModeFull); + p.Add(0x00); + 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(QwpConstants.SchemaModeFull); + p.Add(0x00); + 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(QwpConstants.SchemaModeFull); + p.Add(0x00); + 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(QwpConstants.SchemaModeFull); + p.Add(0x00); + 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_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(QwpConstants.SchemaModeFull); + p.Add(0x00); + 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 { SchemaId = 1, 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(QwpConstants.SchemaModeFull); + 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)); + Assert.That(state.TryGetSchema(1, out _), Is.False); + } + + [Test] + public void Decode_RejectsSymbolDictDeltaStartMismatch() + { + var schema = new ResultSchema { SchemaId = 1, 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())); + } + + 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 { SchemaId = 30, 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 { SchemaId = 31, 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 { SchemaId = 32, 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 { SchemaId = 33, 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 + { + SchemaId = 34, + 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 { SchemaId = 35, 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 { SchemaId = 351, 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 { SchemaId = 36, 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 { SchemaId = 360, 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 { SchemaId = 37, 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 { SchemaId = 40, 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_RejectsUnknownSchemaMode() + { + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + p.Add(0x00); + p.Add(0x00); + p.Add(0x01); + p.Add(0x77); // unknown schema_mode + p.Add(0x00); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("schema_mode", ex!.Message); + } + + [Test] + public void Decode_RejectsReferenceModeForUnknownSchemaId() + { + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x00); + p.Add(0x00); + p.Add(0x00); + p.Add(0x01); + p.Add(QwpConstants.SchemaModeReference); + p.Add(99); + + var decoder = new QwpResultBatchDecoder(new QwpEgressConnState()); + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("unknown schema_id", ex!.Message); + } + + [Test] + public void Decode_RejectsReferenceModeColumnCountMismatch() + { + var schema = new ResultSchema { SchemaId = 50, Columns = { new SchemaColumn("a", QwpTypeCode.Long) } }; + var data = new ResultBatchData { RowCount = 1, Columns = { new FixedColumnData { DenseBytes = LongsLe(1L) } } }; + var fullFrame = QwpEgressFrameBuilder.BuildResultBatch(1L, 0L, schema, data); + + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + decoder.Decode(PayloadOf(fullFrame).Span, HeaderFlagsOf(fullFrame), new QwpColumnBatch()); + + var p = new List { QwpConstants.MsgKindResultBatch }; + p.AddRange(new byte[8]); + p.Add(0x01); + p.Add(0x00); + p.Add(0x01); + p.Add(0x05); // col_count = 5, but registered schema has 1 + p.Add(QwpConstants.SchemaModeReference); + p.Add(50); + + var ex = Assert.Throws(() => decoder.Decode(p.ToArray(), 0, new QwpColumnBatch())); + StringAssert.Contains("col", ex!.Message); + } + + [Test] + public void Decode_RejectsTrailingBytesAfterTableBlock() + { + var schema = new ResultSchema { SchemaId = 60, 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 + { + SchemaId = 90, + 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_OutOfRangeScale_Throws() + { + var schema = new ResultSchema { SchemaId = 80, Columns = { new SchemaColumn("d", QwpTypeCode.Decimal128) } }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 200, DenseBytes = new byte[16] } }, + }; + Assert.Throws(() => DecodeOneBatch(schema, data)); + } + + [Test] + public void Decode_Decimal64_OutOfRangeScale_Throws() + { + var schema = new ResultSchema { SchemaId = 81, Columns = { new SchemaColumn("d", QwpTypeCode.Decimal64) } }; + var data = new ResultBatchData + { + RowCount = 1, + Columns = { new DecimalColumnData { Scale = 25, DenseBytes = new byte[8] } }, + }; + Assert.Throws(() => DecodeOneBatch(schema, data)); + } + + [Test] + public void Decode_RegisterSchemaCollision_RewindsSymbolDict() + { + var state = new QwpEgressConnState(); + var decoder = new QwpResultBatchDecoder(state); + + var schemaA = new ResultSchema { SchemaId = 99, 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)); + + var schemaB = new ResultSchema + { + SchemaId = 99, + Columns = { new SchemaColumn("a", QwpTypeCode.Long), new SchemaColumn("b", QwpTypeCode.Symbol) }, + }; + var dictB = new DeltaSymbolDict { DeltaStart = 0, Entries = { "newSym" } }; + var frameB = QwpEgressFrameBuilder.BuildResultBatch(1L, 1L, schemaB, new ResultBatchData + { + RowCount = 1, + Columns = + { + new FixedColumnData { DenseBytes = LongsLe(7L) }, + new SymbolColumnData { DenseDictIds = new[] { 0 } }, + }, + }, dictB); + + Assert.Throws(() => + decoder.Decode(PayloadOf(frameB).Span, HeaderFlagsOf(frameB), new QwpColumnBatch())); + // Symbol dict rolled back so the next batch sees the pre-failure cursor. + Assert.That(state.SymbolDict.Size, Is.EqualTo(0)); + } +} 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 0000000..5e1f3f5 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Query/QwpRoleFilterTests.cs @@ -0,0 +1,66 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; +using QuestDB.Qwp.Query; + +namespace net_questdb_client_tests.Qwp.Query; + +[TestFixture] +public class QwpRoleFilterTests +{ + [TestCase(QwpConstants.RoleStandalone)] + [TestCase(QwpConstants.RolePrimary)] + [TestCase(QwpConstants.RoleReplica)] + [TestCase(QwpConstants.RolePrimaryCatchup)] + [TestCase((byte)0xFF)] + public void Any_AcceptsEveryRoleByteIncludingFutureUnknowns(byte role) + { + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.any), Is.True); + } + + [TestCase(QwpConstants.RoleStandalone, true)] + [TestCase(QwpConstants.RolePrimary, true)] + [TestCase(QwpConstants.RolePrimaryCatchup, true)] + [TestCase(QwpConstants.RoleReplica, false)] + public void Primary_AcceptsStandalonePrimaryAndCatchup(byte role, bool expected) + { + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.primary), Is.EqualTo(expected)); + } + + [TestCase(QwpConstants.RoleStandalone, false)] + [TestCase(QwpConstants.RolePrimary, false)] + [TestCase(QwpConstants.RolePrimaryCatchup, false)] + [TestCase(QwpConstants.RoleReplica, true)] + public void Replica_AcceptsReplicaOnly(byte role, bool expected) + { + Assert.That(QwpQueryWebSocketClient.RoleMatchesTarget(role, TargetType.replica), Is.EqualTo(expected)); + } +} + +#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 0000000..503c236 --- /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 0000000..30f17d4 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpColumnExtendedTypesTests.cs @@ -0,0 +1,532 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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_Negative_Throws() + { + var col = new QwpColumn("hash", 0); + Assert.Throws(() => col.AppendLong256(new BigInteger(-1))); + } + + [Test] + public void AppendLong256_TooLarge_Throws() + { + var col = new QwpColumn("hash", 0); + var tooBig = BigInteger.One << 256; + Assert.Throws(() => col.AppendLong256(tooBig)); + } + + [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("losslessly")); + } + + [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)); + } + + /// 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 0000000..73371e9 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpColumnTests.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 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 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 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)); + } +} 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 0000000..0d45be9 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpEncoderTests.cs @@ -0,0 +1,710 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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() + { + // Spec §16 example 1 adapted for the .NET WebSocket sender: + // table "sensors" with 2 rows; columns id (LONG), value (DOUBLE), and a designated TS. + // Wire differences from the spec example: FLAG_DELTA_SYMBOL_DICT is always set, so the + // payload begins with an empty delta-dict prelude (0x00 0x00); and the third column is + // the designated timestamp (empty wire name). + 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 QwpSchemaCache(), new QwpSymbolDictionary()); + + // Expected payload layout (78 bytes = 0x4E): + // delta dict : 00 00 + // table name len/bytes: 07 'sensors' + // row count : 02 + // column count : 03 + // schema mode/id : 00 00 + // col defs : 02 'id' 05 | 05 'value' 07 | 00 0A + // col0 (LONG) : 00 | 01 00 00 00 00 00 00 00 | 02 00 00 00 00 00 00 00 + // col1 (DOUBLE) : 00 | <1.3 LE> | <2.2 LE> + // col2 (TIMESTAMP) : 00 | <1e10 LE> | <400000 LE> + var expected = ConcatBytes( + // header + new byte[] { 0x51, 0x57, 0x50, 0x31, 0x01, 0x08, 0x01, 0x00, 0x4E, 0x00, 0x00, 0x00 }, + // delta symbol dict prelude (empty) + new byte[] { 0x00, 0x00 }, + // table header + new byte[] { 0x07 }, + Encoding.UTF8.GetBytes("sensors"), + new byte[] { 0x02, 0x03 }, + // schema + new byte[] { 0x00, 0x00 }, + // column 0 def: "id" LONG + new byte[] { 0x02 }, Encoding.UTF8.GetBytes("id"), new byte[] { 0x05 }, + // column 1 def: "value" DOUBLE + new byte[] { 0x05 }, Encoding.UTF8.GetBytes("value"), new byte[] { 0x07 }, + // column 2 def: designated TS (empty name) TIMESTAMP + new byte[] { 0x00, 0x0A }, + // column 0 data (LONG, no nulls) + new byte[] { 0x00 }, + LittleEndianInt64(1L), + LittleEndianInt64(2L), + // column 1 data (DOUBLE, no nulls) + new byte[] { 0x00 }, + LittleEndianDouble(1.3), + LittleEndianDouble(2.2), + // column 2 data (TIMESTAMP, no nulls) + new byte[] { 0x00 }, + 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_UsesReferenceMode() + { + var cache = new QwpSchemaCache(); + var symbols = new QwpSymbolDictionary(); + + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + + // First encode → full schema, id 0. + var first = QwpEncoder.Encode(new[] { t }, cache, symbols); + Assert.That(t.SchemaId, Is.EqualTo(0)); + + // Recycle the buffer: drop row data but preserve column structure and schema id. + t.Clear(); + Assert.That(t.SchemaId, Is.EqualTo(0), "schema id survives Clear()"); + + t.AppendLong("x", 2); + t.At(1); + + var second = QwpEncoder.Encode(new[] { t }, cache, symbols); + + // Locate the schema-mode byte in each frame: + // header (12) + delta dict (2) + table name varint (1) + "t" (1) + row count (1) + column count (1) = 18 + Assert.That(first[18], Is.EqualTo(QwpConstants.SchemaModeFull), "first batch sends full schema"); + Assert.That(second[18], Is.EqualTo(QwpConstants.SchemaModeReference), + "second batch references schema 0 instead of resending"); + Assert.That(second[19], Is.EqualTo((byte)0), "schema id preserved at 0"); + } + + [Test] + public void Encode_NewColumnMidStream_ResetsSchemaToFull() + { + var cache = new QwpSchemaCache(); + var symbols = new QwpSymbolDictionary(); + + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + QwpEncoder.Encode(new[] { t }, cache, symbols); // schema id 0 allocated and sent. + + // Recycle and add a new column: SchemaId should be invalidated and a fresh id allocated. + t.Clear(); + t.AppendLong("x", 2); + t.AppendDouble("y", 3.14); // new column → invalidates schema id (back to -1) + t.At(1); + + Assert.That(t.SchemaId, Is.EqualTo(-1), "adding a column resets the schema id"); + + var second = QwpEncoder.Encode(new[] { t }, cache, symbols); + + Assert.That(second[18], Is.EqualTo(QwpConstants.SchemaModeFull)); + Assert.That(second[19], Is.EqualTo((byte)1), "fresh schema id allocated"); + Assert.That(t.SchemaId, Is.EqualTo(1)); + } + + [Test] + public void Encode_WithSymbols_EmitsDeltaDictAndVarintIds() + { + var cache = new QwpSchemaCache(); + 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 }, cache, symbols); + + // Header at bytes 0..11: + Assert.That(bytes[QwpConstants.OffsetVersion], Is.EqualTo(QwpConstants.SupportedIngestVersion)); + Assert.That(bytes[QwpConstants.OffsetFlags], Is.EqualTo(QwpConstants.FlagDeltaSymbolDict)); + 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 cache = new QwpSchemaCache(); + 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 }, cache, 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 }, cache, 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 QwpSchemaCache(), new QwpSymbolDictionary()); + + // Find the column-data section. Layout: + // header (12) + delta dict (2) + table header (1+5+1+1=8) + schema (2) + // + col defs: "note" varchar (1+4+1=6) + "" timestamp (1+1=2) + // = 12 + 2 + 8 + 2 + 6 + 2 = 32 → first col data at offset 32. + var pos = 32; + + // 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 cache = new QwpSchemaCache(); + 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 }, cache, symbols); + + Assert.That(BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(QwpConstants.OffsetTableCount, 2)), + Is.EqualTo(2), "two tables in one frame"); + + // Both tables get distinct, sequential schema ids. + Assert.That(t1.SchemaId, Is.EqualTo(0)); + Assert.That(t2.SchemaId, Is.EqualTo(1)); + } + + [Test] + public void Encode_EmptyTablesList_ProducesValidEmptyFrame() + { + var bytes = QwpEncoder.Encode(Array.Empty(), new QwpSchemaCache(), 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_MagicBytesAreCorrect() + { + var bytes = QwpEncoder.Encode(Array.Empty(), new QwpSchemaCache(), 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 QwpSchemaCache(), 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 QwpSchemaCache(), 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 QwpSchemaCache(), 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 QwpSchemaCache(), 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_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 QwpSchemaCache(), 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 QwpSchemaCache(), 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 QwpSchemaCache(), 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 QwpSchemaCache(), 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 QwpSchemaCache(), 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_GorillaEnabled_SetsHeaderFlagAndPrependsEncodingByte() + { + 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 QwpSchemaCache(), new QwpSymbolDictionary(), gorillaEnabled: true); + + // Header flags must include FLAG_GORILLA on top of FLAG_DELTA_SYMBOL_DICT. + var expectedFlags = (byte)(QwpConstants.FlagDeltaSymbolDict | QwpConstants.FlagGorilla); + Assert.That(bytes[QwpConstants.OffsetFlags], Is.EqualTo(expectedFlags)); + + // Layout to the long column data: + // header(12) + delta(2) + tableNameLen(1) + "t"(1) + rowCount(1) + colCount(1) + schema(2) + // + col defs: "v" LONG (1+1+1=3) + "" TIMESTAMP (1+1=2) = 25. + // The long column is fixed-width (LONG, not TIMESTAMP), so no encoding-flag byte for it. + var pos = 25; + Assert.That(bytes[pos++], Is.EqualTo(0x00), "long col null flag"); + // 3 longs = 24 bytes + pos += 24; + + // Designated TS column: encoding-flag should be present because gorillaEnabled is true. + 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_GorillaDisabled_OmitsEncodingByteForTimestamps() + { + var t = new QwpTableBuffer("t"); + t.AppendLong("v", 1L); + t.At(1_000_000L); + + var bytes = QwpEncoder.Encode(new[] { t }, new QwpSchemaCache(), new QwpSymbolDictionary(), gorillaEnabled: false); + + Assert.That(bytes[QwpConstants.OffsetFlags], Is.EqualTo(QwpConstants.FlagDeltaSymbolDict)); + + // Timestamp column data starts at the same offset; first byte is null flag (0), then 8 bytes of TS. + // Layout: header(12) + delta(2) + tableHeader(1+1+1+1=4) + schema(2) + col defs(3+2=5) = 25. + // long col data: null flag (1) + 8 bytes = 9. Then ts col data starts at 25 + 9 = 34. + var pos = 25 + 9; + Assert.That(bytes[pos++], Is.EqualTo(0x00), "ts null flag"); + // Next 8 bytes should be the timestamp directly (no encoding flag preamble). + var ts = BinaryPrimitives.ReadInt64LittleEndian(bytes.AsSpan(pos, 8)); + Assert.That(ts, Is.EqualTo(1_000_000L)); + } + + [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 QwpSchemaCache(), 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 cache = new QwpSchemaCache(); + var symbols = new QwpSymbolDictionary(); + + var t = new QwpTableBuffer("t"); + t.AppendLong("x", 1); + t.At(0); + + var first = QwpEncoder.Encode(new[] { t }, cache, symbols, selfSufficient: true); + // SF mode uses frame-local schema ids (0..tables-1) and never mutates the per-connection + // schemaCache; the table buffer's SchemaId stays unassigned. + Assert.That(t.SchemaId, Is.EqualTo(QwpSchemaCache.UnassignedSchemaId)); + Assert.That(cache.AllocatedCount, Is.EqualTo(0), "SF must not bump the connection counter"); + + t.Clear(); + t.AppendLong("x", 2); + t.At(1); + + var second = QwpEncoder.Encode(new[] { t }, cache, symbols, selfSufficient: true); + + // Both frames must carry the full schema; receiver may have no prior connection state. + Assert.That(first[18], Is.EqualTo(QwpConstants.SchemaModeFull), "first frame: full"); + Assert.That(second[18], Is.EqualTo(QwpConstants.SchemaModeFull), "second frame: still full in SF mode"); + Assert.That(second[19], Is.EqualTo((byte)0), "schema id reused"); + } + + [Test] + public void Encode_SelfSufficient_EmitsFullSymbolDictAfterCommit() + { + var cache = new QwpSchemaCache(); + 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 }, cache, 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 Encode_SelfSufficient_TwoTablesInSameFrame_BothEmitFullSchema() + { + var cache = new QwpSchemaCache(); + 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 }, cache, 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 }, cache, symbols, selfSufficient: true); + + // Walk to each table's schema-mode byte and assert Full. Layout per table: + // tableName varint(1) + name(1) + rowCount(1) + colCount(1) + schemaMode(1) + schemaId(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) + 8 bytes value = 9 + // = 1+1+1+1+1+1+3+2+9+9 = 29 bytes per table block + // First table starts at offset 14 (header 12 + delta 2). Schema mode byte is at +4 inside the + // table block (after name varint + name + rowCount + colCount). + const int firstTableStart = 14; + const int schemaModeOffsetInTable = 4; + const int tableBlockSize = 29; + + Assert.That(bytes[firstTableStart + schemaModeOffsetInTable], Is.EqualTo(QwpConstants.SchemaModeFull), "table A: full"); + Assert.That(bytes[firstTableStart + tableBlockSize + schemaModeOffsetInTable], Is.EqualTo(QwpConstants.SchemaModeFull), "table B: full"); + } + + /// + /// 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) + // + 2 (schema mode + id) + userColDefSize + 2 (designated TS def: empty name varint=0 + TIMESTAMP) + return 12 + 2 + 1 + tableNameLen + 1 + 1 + 2 + 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 0000000..51d6f9e --- /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 0000000..ec8e60c --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpHostHealthTrackerTests.cs @@ -0,0 +1,180 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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)); + } +} 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 0000000..8c977db --- /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 0000000..330cf98 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpResponseTests.cs @@ -0,0 +1,390 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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.OkAckMinSize + 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.OkAckMinSize + 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_Throws() + { + var frame = new byte[QwpConstants.ErrorAckHeaderSize]; + frame[0] = 0xFE; // not a known status code. + + Assert.Throws(() => QwpResponse.Parse(frame)); + } + + [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.OkAckMinSize + 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.OkAckMinSize + 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.OkAckMinSize, 2), (ushort)entries.Length); + + var pos = QwpConstants.OkAckMinSize + 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_DecodedLeniently() + { + // 0xC3 0x28 is a malformed two-byte sequence; replaced with U+FFFD. + 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 resp = QwpResponse.Parse(frame); + Assert.That(resp.Message, Does.Contain("�")); + } + + [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/QwpSchemaCacheTests.cs b/src/net-questdb-client-tests/Qwp/QwpSchemaCacheTests.cs new file mode 100644 index 0000000..8a081b8 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpSchemaCacheTests.cs @@ -0,0 +1,132 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.Utils; + +namespace net_questdb_client_tests.Qwp; + +[TestFixture] +public class QwpSchemaCacheTests +{ + [Test] + public void FreshTable_GetsFullModeAndZeroId() + { + var cache = new QwpSchemaCache(); + var t = new QwpTableBuffer("t"); + + var (mode, id) = cache.PrepareSchema(t); + + Assert.That(mode, Is.EqualTo(QwpConstants.SchemaModeFull)); + Assert.That(id, Is.EqualTo(0)); + Assert.That(t.SchemaId, Is.EqualTo(0)); + Assert.That(cache.MaxSentSchemaId, Is.EqualTo(0)); + } + + [Test] + public void RepeatedCallSameTable_GetsReferenceMode() + { + var cache = new QwpSchemaCache(); + var t = new QwpTableBuffer("t"); + + cache.PrepareSchema(t); + var (mode, id) = cache.PrepareSchema(t); + + Assert.That(mode, Is.EqualTo(QwpConstants.SchemaModeReference)); + Assert.That(id, Is.EqualTo(0), "id is preserved across reference calls"); + } + + [Test] + public void TwoTables_GetSequentialIds() + { + var cache = new QwpSchemaCache(); + var t1 = new QwpTableBuffer("t1"); + var t2 = new QwpTableBuffer("t2"); + + var (_, id1) = cache.PrepareSchema(t1); + var (_, id2) = cache.PrepareSchema(t2); + + Assert.That(id1, Is.EqualTo(0)); + Assert.That(id2, Is.EqualTo(1)); + Assert.That(cache.AllocatedCount, Is.EqualTo(2)); + } + + [Test] + public void ResettingTableSchemaId_ForcesFreshAllocation() + { + var cache = new QwpSchemaCache(); + var t = new QwpTableBuffer("t"); + cache.PrepareSchema(t); + + // Simulate adding a column: caller invalidates schema id. + t.SchemaId = QwpSchemaCache.UnassignedSchemaId; + + var (mode, id) = cache.PrepareSchema(t); + + Assert.That(mode, Is.EqualTo(QwpConstants.SchemaModeFull)); + Assert.That(id, Is.EqualTo(1), "fresh id allocated since the old one was invalidated"); + Assert.That(cache.AllocatedCount, Is.EqualTo(2)); + } + + [Test] + public void ExhaustedSlot_Throws() + { + var cache = new QwpSchemaCache(maxSchemasPerConnection: 2); + var t1 = new QwpTableBuffer("t1"); + var t2 = new QwpTableBuffer("t2"); + var t3 = new QwpTableBuffer("t3"); + + cache.PrepareSchema(t1); + cache.PrepareSchema(t2); + + Assert.Throws(() => cache.PrepareSchema(t3)); + } + + [Test] + public void Reset_ClearsAllState() + { + var cache = new QwpSchemaCache(); + var t = new QwpTableBuffer("t"); + cache.PrepareSchema(t); + + cache.Reset(); + + Assert.That(cache.AllocatedCount, Is.Zero); + Assert.That(cache.MaxSentSchemaId, Is.EqualTo(QwpSchemaCache.UnassignedSchemaId)); + } + + [Test] + public void Reset_ThenReuseTable_EmitsFullSchema() + { + var cache = new QwpSchemaCache(); + var t = new QwpTableBuffer("t"); + cache.PrepareSchema(t); + + cache.Reset(); + + var (mode, _) = cache.PrepareSchema(t); + Assert.That(mode, Is.EqualTo(QwpConstants.SchemaModeFull)); + } +} 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 0000000..e33c48a --- /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 0000000..1d70dea --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpTableBufferTests.cs @@ -0,0 +1,351 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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.SchemaId, Is.EqualTo(-1), "fresh buffer leaves SchemaId at -1 until the encoder allocates one"); + 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_AndInvalidatesSchemaId() + { + var t = new QwpTableBuffer("t"); + + t.AppendDouble("price", 1.0); + t.At(0); + t.AppendDouble("price", 2.0); + t.At(1); + + // Pretend the encoder has assigned a schema id and observed the table. + t.SchemaId = 0; + + // Now add a new column on row 3 (zero-based row 2). This must reset SchemaId. + t.AppendDouble("price", 3.0); + t.AppendLong("volume", 999); + t.At(2); + + Assert.That(t.SchemaId, Is.EqualTo(-1), "adding a column invalidates the schema id"); + 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")); + } + + [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); + } + + 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 0000000..a85188b --- /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 0000000..8b362a3 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpWebSocketSenderTests.cs @@ -0,0 +1,1053 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.SupportedIngestVersion)); + 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 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(); + + using 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.Catch(() => sender.Table("t").Column("v", 2L).At(DateTime.UtcNow)); + Assert.Catch(() => sender.Send()); + } + + [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(); + + // Cursor-engine path emits self-sufficient frames; both frames carry the full schema. + Assert.That(frames[0][18], Is.EqualTo(QwpConstants.SchemaModeFull)); + Assert.That(frames[1][18], Is.EqualTo(QwpConstants.SchemaModeFull)); + } + + [Test] + public async Task EndToEnd_NewColumnMidStream_ResetsToFullSchema() + { + 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(); + + Assert.That(frames[0][18], Is.EqualTo(QwpConstants.SchemaModeFull)); + // Second frame: column count is 3 (v, w, designated TS) so column count varint at offset 17 = 0x03. + // Schema mode is now at offset 18 again, FULL because the column-set changed. + Assert.That(frames[1][18], Is.EqualTo(QwpConstants.SchemaModeFull)); + } + + [Test] + public async Task EndToEnd_SymbolDeltaIsResetEachFlush() + { + 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(); + + // Cursor engine emits self-sufficient frames: each restarts symbol-dict at delta_start=0. + 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(1)); + } + + [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_DoesNotThrow() + { + 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.DoesNotThrowAsync(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;")); + } + + [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 + } + + [Test] + public async Task EndToEnd_TransactionsAreRejected() + { + await using var server = StartServerWithOkAcks(); + using var sender = NewSender(server, "auto_flush=off;"); + + Assert.Throws(() => sender.Transaction("t")); + } + + [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 + { + 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(); + using var sender = NewSender(server, "auto_flush=off;request_durable_ack=on;"); + + 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 ws.PingAsync(); + + 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() + { + await using var server = StartServerWithOkAcks(); + 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(); + using 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(); // first batch — OK + + sender.Table("t").Column("v", 2L).At(DateTime.UtcNow); + sender.Send(); + Assert.CatchAsync(async () => await qwp.PingAsync()); + } + + [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)); + const int schemaModeOffset = 12 + 2 + 8 + 1 + 6 + 1 + 1; + foreach (var frame in server.ReceivedFrames) + { + Assert.That(frame[schemaModeOffset], Is.EqualTo(QwpConstants.SchemaModeFull), + "every SF frame must carry full schema"); + 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 must always be in self-sufficient form: schema mode = Full. + // Header(12) + delta dict prelude(2 + "ETH-USD"=8) + table name varint(1) + "trades"(6) + // + rowCount(1) + colCount(1) + schemaMode(1) = byte index 32. + const int schemaModeOffset = 12 + 2 + 8 + 1 + 6 + 1 + 1; + Assert.That(frame[schemaModeOffset], Is.EqualTo(QwpConstants.SchemaModeFull), + "SF frame must carry full schema (replayable against fresh server state)"); + + // Delta dict starts at id 0 with the full known set, even after the engine commits. + 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 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); + 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); + 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 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("1.2.3.4")); + sender.At(DateTime.UtcNow); + await sender.SendAsync(); + await ws.PingAsync(); + + var payload = server.ReceivedFrames.First().AsSpan(); + Assert.That(payload.IndexOf((byte)QwpTypeCode.IPv4), Is.GreaterThan(0)); + } + + [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"))); + } + + 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); + } + + private static byte[] BuildOkAck(long sequence) + { + var bytes = new byte[QwpConstants.OkAckMinSize + 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.OkAckMinSize + 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.OkAckMinSize, 2), (ushort)entries.Length); + + var pos = QwpConstants.OkAckMinSize + 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 PostTerminal_MutatorsThrow() + { + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + FrameHandler = _ => BuildErrorAck(QwpStatusCode.WriteError, 0, "boom"), + }); + await server.StartAsync(); + using 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(); + try { await qwp.PingAsync(); } catch { /* expected terminal */ } + + Assert.Throws(() => sender.Truncate()); + Assert.Throws(() => sender.CancelRow()); + Assert.Throws(() => sender.Clear()); + } + + 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 { } + } + } +} + +#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 0000000..a239c07 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/QwpWebSocketTransportTests.cs @@ -0,0 +1,400 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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_Rejected() + { + // A WebSocket service that doesn't surface X-QWP-Version isn't proven to be a QWP server; + // accepting the upgrade silently would deadlock on the first frame send. + await using var server = new DummyQwpServer(new DummyQwpServerOptions + { + NegotiatedVersion = null, + }); + await server.StartAsync(); + + using var transport = new QwpWebSocketTransport(new QwpWebSocketTransportOptions + { + Uri = server.Uri, + }); + + var ex = Assert.ThrowsAsync(async () => await transport.ConnectAsync()); + Assert.That(ex!.code, Is.EqualTo(ErrorCode.ProtocolVersionError)); + } + + [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.ThrowsAsync(async () => await transport.ConnectAsync()); + } + + [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(); + 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_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)); + } + + [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 0000000..25fa657 --- /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/QwpBackgroundDrainerPoolTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerPoolTests.cs new file mode 100644 index 0000000..038cb7c --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerPoolTests.cs @@ -0,0 +1,342 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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_TransientFailure_LeavesSlotForRetry() + { + 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.False, + "transient errors must not permanently quarantine the slot"); + using var reacquired = QwpSlotLock.Acquire(slotDir); + Assert.That(reacquired.SlotDirectory, Is.EqualTo(slotDir)); + } + + [Test] + public async Task Enqueue_GenericException_LeavesSlotForRetry() + { + 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.False); + } + + [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_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 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 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 0000000..dd0bd13 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpBackgroundDrainerTests.cs @@ -0,0 +1,244 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + 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 0000000..6af7382 --- /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/QwpCursorSendEngineMultiHostTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineMultiHostTests.cs new file mode 100644 index 0000000..595beee --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineMultiHostTests.cs @@ -0,0 +1,254 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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(); + + private readonly string? _rejectWithRole; + private readonly Channel _acks = Channel.CreateUnbounded(); + private int _autoSeq; + + public MhStubTransport(string? rejectWithRole, string hostId) + { + _rejectWithRole = rejectWithRole; + HostId = hostId; + } + + 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 0000000..f3eef75 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpCursorSendEngineTests.cs @@ -0,0 +1,920 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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 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[] { 1 }); + engine.AppendBlocking(new byte[] { 2 }); + engine.AppendBlocking(new byte[] { 3 }); + + 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 allSent = stubs.SelectMany(s => s.Sent.Select(b => b[0])).ToArray(); + Assert.That(allSent, Does.Contain((byte)1)); + Assert.That(allSent, Does.Contain((byte)2)); + Assert.That(allSent, Does.Contain((byte)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++) + { + engine.AppendBlocking(new byte[] { (byte)(i & 0xFF) }); + } + + 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"); + } + + + 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(); + + 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 0000000..3d34b60 --- /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/QwpFilesTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpFilesTests.cs new file mode 100644 index 0000000..f4fc9c3 --- /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/QwpMmapSegmentTests.cs b/src/net-questdb-client-tests/Qwp/Sf/QwpMmapSegmentTests.cs new file mode 100644 index 0000000..76bc48d --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpMmapSegmentTests.cs @@ -0,0 +1,343 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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_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 0000000..b6ad380 --- /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 0000000..ad78ebe --- /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 0000000..b7a0058 --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentManagerTests.cs @@ -0,0 +1,213 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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 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); + 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(2)); + 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(2)); + sw.Stop(); + + Assert.That(sw.Elapsed, Is.LessThan(QwpSegmentManager.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)); + } + + 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 0000000..17dc2bc --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSegmentRingTests.cs @@ -0,0 +1,228 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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); + } + } + } +} 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 0000000..be5e09f --- /dev/null +++ b/src/net-questdb-client-tests/Qwp/Sf/QwpSlotLockTests.cs @@ -0,0 +1,131 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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)); + } +} 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 0000000..08c1718 --- /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/SenderOptionsTests.cs b/src/net-questdb-client-tests/SenderOptionsTests.cs index 92c68bb..d286e0f 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;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;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,861 @@ public void GzipInToString() var senderOptions = new SenderOptions("http::addr=localhost:9000;gzip=true;"); Assert.That(senderOptions.ToString(), Does.Contain("gzip=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 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(5))); + 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 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[] + { + "max_schemas_per_connection=1024", + "gorilla=off", "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(0)); + Assert.That(opts.auto_flush_interval, Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(opts.Port, Is.EqualTo(9000)); + Assert.That(opts.max_schemas_per_connection, Is.EqualTo(65535)); + Assert.That(opts.request_durable_ack, Is.False); + Assert.That(opts.gorilla, Is.True); + } + + [Test] + public void UnknownKey_IsSilentlyIgnored() + { + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;some_unrecognised_key=42;"), + Throws.Nothing); + } + + [Test] + public void Ws_AuthTimeoutMs_AliasParses() + { + var withMs = new SenderOptions("ws::addr=localhost:9000;auth_timeout_ms=2500;"); + Assert.That(withMs.auth_timeout, Is.EqualTo(TimeSpan.FromMilliseconds(2500))); + + var legacy = new SenderOptions("ws::addr=localhost:9000;auth_timeout=2500;"); + Assert.That(legacy.auth_timeout, Is.EqualTo(TimeSpan.FromMilliseconds(2500))); + } + + [Test] + public void Ws_ToString_EmitsAuthTimeoutMs() + { + var opts = new SenderOptions("ws::addr=localhost:9000;auth_timeout=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); + } + + [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_LessThanOne_Rejected() + { + var opts = new SenderOptions + { + protocol = ProtocolType.ws, addr = "h:9000", sf_dir = "/tmp/qdb", error_inbox_capacity = 0, + }; + Assert.That( + () => opts.EnsureValid(), + Throws.TypeOf().With.Message.Contains(">= 1")); + } + + [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_Accepted() + { + Assert.DoesNotThrow(() => new SenderOptions("ws::addr=localhost:9000;gzip=false;")); + } + + [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;max_schemas_per_connection=1024;"); + var flipped = ws with { protocol = QuestDB.Enums.ProtocolType.http }; + + Assert.That( + () => QuestDB.Sender.New(flipped), + Throws.TypeOf().With.Message.Contains("max_schemas_per_connection")); + } + + [Test] + public void Programmatic_HttpSenderWithWsOnlyKey_Rejected() + { + var opts = new SenderOptions + { + protocol = QuestDB.Enums.ProtocolType.http, + addr = "localhost:9000", + max_schemas_per_connection = 1024, + }; + + Assert.That( + () => QuestDB.Sender.New(opts), + Throws.TypeOf().With.Message.Contains("max_schemas_per_connection")); + } + + [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("ON", true)] + [TestCase("off", false)] + [TestCase("OFF", false)] + [TestCase("true", true)] + [TestCase("TRUE", true)] + [TestCase("false", false)] + public void Gorilla_AcceptsBothBooleanForms(string raw, bool expected) + { + var opts = new SenderOptions($"ws::addr=localhost:9000;gorilla={raw};"); + Assert.That(opts.gorilla, 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() + { + Assert.That( + () => new SenderOptions("ws::addr=localhost:9000;gzip=on;"), + Throws.TypeOf().With.Message.Contains("ws")); + } + + [Test] + public void RecordWith_MutatingWsKeyAfterFlip_StillRejected() + { + var ws = new SenderOptions("ws::addr=localhost:9000;"); + var flipped = ws with { protocol = QuestDB.Enums.ProtocolType.http, max_schemas_per_connection = 1024 }; + + Assert.That( + () => QuestDB.Sender.New(flipped), + Throws.TypeOf().With.Message.Contains("max_schemas_per_connection")); + } + + [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", + max_schemas_per_connection = 65535, + }; + + Assert.That( + () => QuestDB.Sender.New(opts), + Throws.TypeOf().With.Message.Contains("max_schemas_per_connection")); + } + + [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;max_schemas_per_connection=1024;ping_timeout=2500;"); + var rt = new SenderOptions(opts.ToString()); + Assert.That(rt.max_schemas_per_connection, Is.EqualTo(1024)); + 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", "max_schemas_per_connection=10")] + [TestCase("http", "gorilla=on")] + [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("https", "gorilla=on")] + [TestCase("https", "ping_timeout=1000")] + [TestCase("https", "proxy=http://p:8080")] + [TestCase("tcp", "gorilla=on")] + [TestCase("tcp", "ping_timeout=1000")] + [TestCase("tcp", "proxy=http://p:8080")] + [TestCase("tcps", "gorilla=on")] + [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"); + } } \ No newline at end of file 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 0000000..50eb5c1 --- /dev/null +++ b/src/net-questdb-client-tests/Utils/QwpTlsAuthTests.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. + * + ******************************************************************************/ + +using NUnit.Framework; +using QuestDB.Enums; +using QuestDB.Utils; + +namespace net_questdb_client_tests.Utils; + +[TestFixture] +public class QwpTlsAuthTests +{ + [Test] + public void BuildAuthHeader_RawAuth_ReturnedVerbatim() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: null, password: null, token: null, rawAuth: "Bearer abc123"); + Assert.That(header, Is.EqualTo("Bearer abc123")); + } + + [Test] + public void BuildAuthHeader_RawAuth_WinsOverBasicAndBearer() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "user", password: "pass", token: "tok", rawAuth: "Custom xyz"); + Assert.That(header, Is.EqualTo("Custom xyz")); + } + + [Test] + public void BuildAuthHeader_BasicEncodesUtf8Pair() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "alice", password: "s3cret", token: null, rawAuth: 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, rawAuth: 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", rawAuth: null); + Assert.That(header, Is.EqualTo("Bearer tok")); + } + + [Test] + public void BuildAuthHeader_BasicTakesPrecedenceOverBearer() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "u", password: "p", token: "tok", rawAuth: null); + Assert.That(header, Does.StartWith("Basic ")); + } + + [Test] + public void BuildAuthHeader_UsernameWithoutPassword_FallsThroughToToken() + { + var header = QwpTlsAuth.BuildAuthHeader( + username: "u", password: null, token: "tok", rawAuth: null); + Assert.That(header, Is.EqualTo("Bearer tok")); + } + + [Test] + public void BuildAuthHeader_AllNull_ReturnsNull() + { + Assert.That(QwpTlsAuth.BuildAuthHeader(null, null, null, null), Is.Null); + } + + [Test] + public void BuildAuthHeader_AllEmptyStrings_TreatedAsUnset() + { + Assert.That(QwpTlsAuth.BuildAuthHeader("", "", "", ""), Is.Null); + } + + [Test] + public void BuildCertificateValidator_UnsafeOff_AlwaysTrueCallback() + { + var cb = QwpTlsAuth.BuildCertificateValidator(TlsVerifyType.unsafe_off, null, null); + Assert.That(cb, Is.Not.Null); + 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_ReturnsCallback() + { + var cb = QwpTlsAuth.BuildCertificateValidator(TlsVerifyType.on, "/some/path/ca.pem", null); + Assert.That(cb, Is.Not.Null); + } +} 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 030eb01..d63f0d6 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,7 @@ enable enable false + latest net6.0;net7.0;net8.0;net9.0;net10.0 diff --git a/src/net-questdb-client/Buffers/BufferV1.cs b/src/net-questdb-client/Buffers/BufferV1.cs index 7a61a0c..8f47a94 100644 --- a/src/net-questdb-client/Buffers/BufferV1.cs +++ b/src/net-questdb-client/Buffers/BufferV1.cs @@ -113,11 +113,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 static DateTime NormaliseToUtc(DateTime value) => value.Kind == DateTimeKind.Local + ? value.ToUniversalTime() + : value; + /// public void At(DateTimeOffset timestamp) { @@ -354,7 +359,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/Enums/CompressionType.cs b/src/net-questdb-client/Enums/CompressionType.cs new file mode 100644 index 0000000..f6c441b --- /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/InitialConnectMode.cs b/src/net-questdb-client/Enums/InitialConnectMode.cs new file mode 100644 index 0000000..be5f7ad --- /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/LoadBalanceStrategy.cs b/src/net-questdb-client/Enums/LoadBalanceStrategy.cs new file mode 100644 index 0000000..f8438d8 --- /dev/null +++ b/src/net-questdb-client/Enums/LoadBalanceStrategy.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; + +/// Initial address-pick strategy for the multi-endpoint egress client. +/// +/// Failover after the initial pick walks the list deterministically in either case. +/// +/// — pick a random address from the list (default); spreads +/// clients across endpoints without server-side coordination. +/// — always start at addresses[0]; deterministic, useful +/// for tests and primary-preferred topologies. +/// +/// +public enum LoadBalanceStrategy +{ + random, + first, +} diff --git a/src/net-questdb-client/Enums/ProtocolType.cs b/src/net-questdb-client/Enums/ProtocolType.cs index e16bfa5..20d8449 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 0000000..8e346d8 --- /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/QwpStatusCode.cs b/src/net-questdb-client/Enums/QwpStatusCode.cs new file mode 100644 index 0000000..4f79a07 --- /dev/null +++ b/src/net-questdb-client/Enums/QwpStatusCode.cs @@ -0,0 +1,55 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 (spec §13). +/// +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, +} diff --git a/src/net-questdb-client/Enums/QwpTypeCode.cs b/src/net-questdb-client/Enums/QwpTypeCode.cs new file mode 100644 index 0000000..7b1bba4 --- /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 0000000..c3db07a --- /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 0000000..a6b8f5e --- /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 0000000..edfefee --- /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 0000000..7070770 --- /dev/null +++ b/src/net-questdb-client/QueryClient.cs @@ -0,0 +1,81 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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)); + } + + /// 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 0000000..1a802dc --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QueryOptions.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 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 +{ + // `initial_credit` is intentionally not in this set; it's programmatic-only. + private static readonly HashSet KeySet = new(StringComparer.Ordinal) + { + "addr", + "path", + "auth", "username", "password", "token", + "tls_verify", "tls_roots", "tls_roots_password", + "compression", "compression_level", + "target", "failover", "failover_max_attempts", + "failover_backoff_initial_ms", "failover_backoff_max_ms", + "failover_max_duration_ms", + "auth_timeout_ms", + "lb_strategy", + "max_batch_rows", + "client_id", + "buffer_pool_size", + }; + + 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; + + /// Pre-built Authorization header value; mutually exclusive with the username/password/token combinations. + public string? auth { get; set; } + /// 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; } = 3; + + /// Server-side target preference forwarded with the upgrade request. + public TargetType target { get; set; } = TargetType.any; + /// Initial address-pick strategy across the configured endpoints. + public LoadBalanceStrategy lb_strategy { get; set; } = LoadBalanceStrategy.random; + /// 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); + + /// 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. Programmatic-only (no connect-string key); set via object initializer. + /// + public long initial_credit { get; init; } + + /// + /// 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(auth), auth); + RejectControlChars(nameof(client_id), client_id); + RejectControlChars(nameof(path), path); + } + + 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 (!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)!; + auth = ReadString(builder, "auth"); + 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 = ReadEnum(builder, "compression", CompressionType.raw); + compression_level = ReadInt(builder, "compression_level", 3); + + target = ReadEnum(builder, "target", TargetType.any); + lb_strategy = ReadEnum(builder, "lb_strategy", LoadBalanceStrategy.random); + 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)); + + 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; + } + } + + private void ValidateAddress() + { + if (string.IsNullOrEmpty(addr)) + { + throw new IngressError(ErrorCode.ConfigError, "`addr` is required"); + } + } + + private void ValidateAuthCombination() + { + var hasAuth = !string.IsNullOrEmpty(auth); + var hasUsername = !string.IsNullOrEmpty(username); + var hasPassword = !string.IsNullOrEmpty(password); + var hasToken = !string.IsNullOrEmpty(token); + + var modes = (hasAuth ? 1 : 0) + ((hasUsername || hasPassword) ? 1 : 0) + (hasToken ? 1 : 0); + if (modes > 1) + { + throw new IngressError(ErrorCode.ConfigError, + "`auth`, `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() + { + 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 || failover_backoff_max_ms < TimeSpan.Zero) + { + throw new IngressError(ErrorCode.ConfigError, + "`failover_backoff_*_ms` must be non-negative"); + } + + 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 (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 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 0000000..d5811b8 --- /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 0000000..b6f0029 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpBindValues.cs @@ -0,0 +1,497 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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. + 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 0000000..8dc2b08 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpColumnBatch.cs @@ -0,0 +1,662 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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); + 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); + 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); + 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); + 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); + var i = DenseIndex(c, row); + if (i < 0) return 0; + return BinaryPrimitives.ReadInt64LittleEndian(c.ValueBytes.AsSpan(i * 8, 8)); + } + + /// Returns the FLOAT (32-bit); 0 for NULL. + public float GetFloatValue(int col, int row) + { + var c = Col(col); + 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); + 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 GetLongValue(col, 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 + { + QwpTypeCode.Varchar or QwpTypeCode.Symbol => QwpConstants.StrictUtf8.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); + 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 c = Col(col); + var i = DenseIndex(c, row); + if (i < 0) return 0; + var start = c.StringOffsets![i]; + return c.ValueBytes[start]; + } + + /// Returns the DOUBLE_ARRAY element bytes as a span over the column scratch; valid only for the duration of the handler. + public ReadOnlySpan GetDoubleArraySpan(int col, int row) + { + var (heap, start, end, nDims) = ArraySpan(col, row); + if (nDims < 0) return ReadOnlySpan.Empty; + var valuesStart = start + 1 + nDims * 4; + var valueByteCount = end - valuesStart; + return System.Runtime.InteropServices.MemoryMarshal + .Cast(heap.AsSpan(valuesStart, valueByteCount)); + } + + /// Returns the LONG_ARRAY element bytes as a span over the column scratch; valid only for the duration of the handler. + public ReadOnlySpan GetLongArraySpan(int col, int row) + { + var (heap, start, end, nDims) = ArraySpan(col, row); + if (nDims < 0) return ReadOnlySpan.Empty; + var valuesStart = start + 1 + nDims * 4; + var valueByteCount = end - valuesStart; + return System.Runtime.InteropServices.MemoryMarshal + .Cast(heap.AsSpan(valuesStart, 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; + } + + private (byte[] Heap, int Start, int End, int NDims) ArraySpan(int col, int row) + { + var c = Col(col); + 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]; + 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); + var i = DenseIndex(c, row); + if (i < 0) return 0; + return c.ValueBytes[i]; + } +} + +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 0000000..b101def --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpColumnBatchHandler.cs @@ -0,0 +1,50 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/// +/// 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(byte status, string message) { } + + /// Terminator for non-row-returning ops (DDL/DML); identifies the operation, the row count when applicable. + public virtual void OnExecDone(short 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 0000000..bbb1a77 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpDecodeException.cs @@ -0,0 +1,31 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +internal sealed class QwpDecodeException : Exception +{ + public QwpDecodeException(string message) : base(message) { } + 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 0000000..4761620 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpEgressConnState.cs @@ -0,0 +1,89 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +internal sealed class QwpEgressConnState +{ + private readonly Dictionary _schemas = new(); + + public QwpEgressSymbolDict SymbolDict { get; } = new(); + + public bool TryGetSchema(ulong schemaId, out EgressSchema schema) => + _schemas.TryGetValue(schemaId, out schema!); + + public void RegisterSchema(ulong schemaId, EgressSchema schema) + { + if (_schemas.TryGetValue(schemaId, out var existing)) + { + if (!SchemasEqual(existing, schema)) + { + throw new QwpDecodeException( + $"schema_id {schemaId} re-registered with a different layout " + + $"(was {existing.Columns.Length} columns, now {schema.Columns.Length})"); + } + return; + } + _schemas[schemaId] = schema; + } + + public void ResetSymbolDict() => SymbolDict.Reset(); + + public void ResetSchemas() => _schemas.Clear(); + + private static bool SchemasEqual(EgressSchema a, EgressSchema b) + { + if (a.Columns.Length != b.Columns.Length) return false; + for (var i = 0; i < a.Columns.Length; i++) + { + if (a.Columns[i].TypeCode != b.Columns[i].TypeCode) return false; + if (a.Columns[i].Name != b.Columns[i].Name) return false; + } + return true; + } +} + +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 0000000..fea6ea8 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpQueryWebSocketClient.cs @@ -0,0 +1,1096 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 const int InitialDecompressBufferBytes = 256 * 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 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 int _disposed; + private int _terminal; + private int _cancelRequested; + // 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); + var walkOrder = new List(options.addresses); + if (options.lb_strategy == LoadBalanceStrategy.random && walkOrder.Count > 1) + { + for (var i = walkOrder.Count - 1; i > 0; i--) + { + var j = Random.Shared.Next(i + 1); + (walkOrder[i], walkOrder[j]) = (walkOrder[j], walkOrder[i]); + } + } + _hostTracker = new QwpHostHealthTracker(walkOrder); + } + + 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; + Interlocked.Exchange(ref _cancelRequested, 0); + _hostTracker.BeginRound(forgetClassifications: false); + try + { + var attempt = 0; + var backoffMs = _options.failover_backoff_initial_ms.TotalMilliseconds; + var failoverDeadline = _options.failover_max_duration_ms > TimeSpan.Zero + ? Environment.TickCount64 + (long)_options.failover_max_duration_ms.TotalMilliseconds + : long.MaxValue; + while (true) + { + var requestId = Interlocked.Increment(ref _nextRequestId); + Interlocked.Exchange(ref _currentRequestId, requestId); + _pendingCreditBytes = 0; + 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 + && Volatile.Read(ref _cancelRequested) == 0 + && Volatile.Read(ref _disposed) == 0) + { + if (_activeAddressIndex >= 0) _hostTracker.RecordMidStreamFailure(_activeAddressIndex); + var sleep = QwpReconnectPolicy.FullJitter(TimeSpan.FromMilliseconds(backoffMs)); + if (sleep > _options.failover_backoff_max_ms) sleep = _options.failover_backoff_max_ms; + 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 (Volatile.Read(ref _cancelRequested) != 0) + { + throw new OperationCanceledException("query cancelled during failover"); + } + backoffMs = Math.Min(backoffMs * 2, _options.failover_backoff_max_ms.TotalMilliseconds); + attempt++; + await ReconnectAsync(attempt, ct).ConfigureAwait(false); + if (Volatile.Read(ref _cancelRequested) != 0) + { + throw new OperationCanceledException("query cancelled during failover"); + } + try + { + handler.OnFailoverReset(ServerInfo); + } + catch + { + _drainOkAfterHandlerThrow = true; + throw; + } + } + } + } + finally + { + if (!_executeFinishedCleanly && !_drainOkAfterHandlerThrow) MarkTerminal(); + Interlocked.Exchange(ref _currentRequestId, -1); + _executeLock.Release(); + } + } + + private static bool IsTransportError(Exception ex) + { + // ProtocolVersionError is intentionally excluded — frame-decode corruption is permanent; + // failing over to the next endpoint masks server bugs and burns retry budget. + return ex switch + { + IngressError ie => ie.code == ErrorCode.SocketError, + 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(); + _connState.ResetSchemas(); + 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; + 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"); + } + } + + if (candidate.NegotiatedVersion >= 2) + { + 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"); + } + } + else + { + info = null; + } + + lastInfo = info; + if (EndpointMatchesTarget(info)) + { + 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 == QwpConstants.RolePrimaryCatchup); + 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.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; + Interlocked.Exchange(ref _cancelRequested, 1); + 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 { } + } + } + + 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 { } + } + } + + 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) + { + 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.SupportedEgressVersion, + ClientId = _options.client_id, + AuthorizationHeader = QwpTlsAuth.BuildAuthHeader( + _options.username, _options.password, _options.token, _options.auth), + RemoteCertificateValidationCallback = QwpTlsAuth.BuildCertificateValidator( + _options.tls_verify, _options.tls_roots, _options.tls_roots_password), + 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}"); + } + + private static QwpServerInfo SynthesiseRoleRejectInfo(QwpIngressRoleRejectedException ex) => new() + { + Role = ex.Role switch + { + QwpConstants.RoleStandaloneName => QwpConstants.RoleStandalone, + QwpConstants.RolePrimaryName => QwpConstants.RolePrimary, + QwpConstants.RoleReplicaName => QwpConstants.RoleReplica, + QwpConstants.RolePrimaryCatchupName => QwpConstants.RolePrimaryCatchup, + _ => byte.MaxValue, + }, + }; + + // v1 server (no SERVER_INFO) only matches target=any; primary/replica must skip it. + private bool EndpointMatchesTarget(QwpServerInfo? info) + { + if (info is not null) return RoleMatchesTarget(info.Role, _options.target); + return _options.target == TargetType.any; + } + + internal static bool RoleMatchesTarget(byte role, TargetType target) + { + return target switch + { + TargetType.any => true, + TargetType.primary => role is QwpConstants.RoleStandalone or QwpConstants.RolePrimary + or QwpConstants.RolePrimaryCatchup, + TargetType.replica => role == QwpConstants.RoleReplica, + _ => 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, + $"v2 server must send SERVER_INFO as the first frame, got 0x{(byte)kind:X2}"); + } + return DecodeServerInfo(payload); + } + + private async Task DriveQueryLoopAsync(QwpColumnBatchHandler handler, CancellationToken ct) + { + var activeRid = Volatile.Read(ref _currentRequestId); + 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.ProtocolVersionError, ex.Message, ex); + } + var batchRid = _batch.RequestId; + if (batchRid != activeRid) + { + continue; + } + 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) continue; + try { handler.OnEnd(endTotal); } catch { MarkTerminal(); throw; } + _executeFinishedCleanly = true; + return; + + case QwpEgressMsgKind.ExecDone: + var (execRid, opType, rowsAffected) = DecodeExecDone(payload); + if (execRid != activeRid) continue; + try { handler.OnExecDone(opType, rowsAffected); } catch { MarkTerminal(); throw; } + _executeFinishedCleanly = true; + return; + + case QwpEgressMsgKind.QueryError: + var (errRid, status, message) = DecodeQueryError(payload); + if (errRid != activeRid && errRid != QwpConstants.RequestIdWildcard) continue; + if (errRid == QwpConstants.RequestIdWildcard) + { + Interlocked.Exchange(ref _transport, null)?.Dispose(); + MarkTerminal(); + } + try { handler.OnError(status, message); } catch { MarkTerminal(); throw; } + _executeFinishedCleanly = true; + return; + + case QwpEgressMsgKind.CacheReset: + DecodeCacheReset(payload); + break; + + case QwpEgressMsgKind.ServerInfo: + throw new IngressError(ErrorCode.ProtocolVersionError, + "unexpected SERVER_INFO mid-query"); + + default: + throw new IngressError(ErrorCode.ProtocolVersionError, + $"unknown egress frame 0x{(byte)kind:X2}"); + } + } + } + + 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.ProtocolVersionError, $"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.ProtocolVersionError, $"bad QWP1 magic 0x{magic:X8}"); + } + + var version = hdr[QwpConstants.OffsetVersion]; + if (version > QwpConstants.SupportedEgressVersion) + { + throw new IngressError(ErrorCode.ProtocolVersionError, $"unsupported QWP version {version}"); + } + + var flags = hdr[QwpConstants.OffsetFlags]; + var payloadLen = (int)BinaryPrimitives.ReadUInt32LittleEndian(hdr.Slice(QwpConstants.OffsetPayloadLength, 4)); + if (QwpConstants.HeaderSize + payloadLen != read) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"payload_length mismatch: header says {payloadLen}, frame is {read - QwpConstants.HeaderSize}"); + } + + if (payloadLen < 1) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "frame payload missing msg_kind byte"); + } + + var payloadMem = buffer.AsMemory(QwpConstants.HeaderSize, payloadLen); + return ((QwpEgressMsgKind)payloadMem.Span[0], 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.ProtocolVersionError, "compressed RESULT_BATCH missing prelude"); + } + + int seqBytes; + try + { + QwpVarint.Read(span.Slice(9), out seqBytes); + } + catch (Exception ex) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + "compressed RESULT_BATCH: malformed batch_seq varint", ex); + } + var preludeLen = 1 + 8 + seqBytes; + if (preludeLen > span.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "compressed RESULT_BATCH prelude truncated"); + } + + var compressed = span.Slice(preludeLen); + if (compressed.IsEmpty) + { + throw new IngressError(ErrorCode.ProtocolVersionError, "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.ProtocolVersionError, "zstd frame: malformed content-size"); + } + + int attemptSize; + bool sizeKnown; + if (declaredSize == ContentSizeUnknown) + { + attemptSize = InitialDecompressBufferBytes; + sizeKnown = false; + } + else if (declaredSize > (ulong)QwpConstants.MaxResultBatchWireBytes) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"zstd frame reports decompressed size {declaredSize}, exceeds {QwpConstants.MaxResultBatchWireBytes}"); + } + else + { + attemptSize = (int)declaredSize; + sizeKnown = true; + } + + var decompressor = _decompressor ??= new ZstdSharp.Decompressor(); + while (true) + { + var needed = preludeLen + attemptSize; + if (_decompressBuffer.Length < needed) + { + _decompressBuffer = new byte[needed]; + } + span.Slice(0, preludeLen).CopyTo(_decompressBuffer); + + int written; + try + { + written = decompressor.Unwrap(compressed, _decompressBuffer.AsSpan(preludeLen, attemptSize)); + return _decompressBuffer.AsMemory(0, preludeLen + written); + } + catch (Exception ex) when (!sizeKnown && attemptSize < QwpConstants.MaxResultBatchWireBytes + && IsZstdDestinationTooSmall(ex)) + { + attemptSize = (int)Math.Min((long)attemptSize * 2, QwpConstants.MaxResultBatchWireBytes); + } + catch (Exception ex) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"zstd RESULT_BATCH decompression failed (attemptSize={attemptSize}, sizeKnown={sizeKnown}): {ex.Message}", + ex); + } + } + } + + private static bool IsZstdDestinationTooSmall(Exception ex) + { + var msg = ex.Message ?? string.Empty; + return msg.Contains("Destination", StringComparison.OrdinalIgnoreCase) + || msg.Contains("dstSize", StringComparison.Ordinal) + || msg.Contains("buffer too small", StringComparison.OrdinalIgnoreCase); + } + + 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(); + } + } + + private static QwpServerInfo DecodeServerInfo(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 1 + 8 + 4 + 8 + 2) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"SERVER_INFO too short: {s.Length} bytes"); + } + + if (s[0] != QwpConstants.MsgKindServerInfo) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"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.ProtocolVersionError, "SERVER_INFO truncated at cluster_id"); + } + var clusterId = StrictUtf8.GetString(s.Slice(24, clusterIdLen)); + 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.ProtocolVersionError, "SERVER_INFO truncated at node_id"); + } + if (s.Length != nodeIdStart + nodeIdLen) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"SERVER_INFO length mismatch: consumed {nodeIdStart + nodeIdLen}, payload {s.Length}"); + } + var nodeId = StrictUtf8.GetString(s.Slice(nodeIdStart, nodeIdLen)); + + return new QwpServerInfo + { + Role = role, + Epoch = epoch, + Capabilities = capabilities, + ServerWallNs = serverWallNs, + ClusterId = clusterId, + NodeId = nodeId, + }; + } + + private static (long RequestId, long TotalRows) DecodeResultEnd(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 8) throw new IngressError(ErrorCode.ProtocolVersionError, "RESULT_END too short"); + var requestId = BinaryPrimitives.ReadInt64LittleEndian(s.Slice(1, 8)); + var p = 9; + QwpVarint.Read(s.Slice(p), out var consumed1); // final_seq is informational; not surfaced. + p += consumed1; + var totalRows = (long)QwpVarint.Read(s.Slice(p), out var consumed2); + p += consumed2; + if (p != s.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"RESULT_END trailing bytes: consumed {p}, payload {s.Length}"); + } + return (requestId, totalRows); + } + + private static (long RequestId, short OpType, long RowsAffected) DecodeExecDone(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length < 1 + 8 + 1 + 1) throw new IngressError(ErrorCode.ProtocolVersionError, "EXEC_DONE too short"); + var requestId = BinaryPrimitives.ReadInt64LittleEndian(s.Slice(1, 8)); + short opType = s[9]; + var rowsAffected = (long)QwpVarint.Read(s.Slice(10), out var consumed); + var p = 10 + consumed; + if (p != s.Length) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"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.ProtocolVersionError, "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.ProtocolVersionError, + $"QUERY_ERROR length mismatch: msgLen={msgLen} payload={s.Length}"); + } + var msg = StrictUtf8.GetString(s.Slice(12, msgLen)); + return (requestId, status, msg); + } + + private void DecodeCacheReset(ReadOnlyMemory payload) + { + var s = payload.Span; + if (s.Length != 2) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"CACHE_RESET length mismatch: payload={s.Length}, expected 2"); + } + var mask = s[1]; + if ((mask & QwpConstants.ResetMaskDict) != 0) _connState.ResetSymbolDict(); + if ((mask & QwpConstants.ResetMaskSchemas) != 0) _connState.ResetSchemas(); + } + + 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 0000000..5b6b119 --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpResultBatchDecoder.cs @@ -0,0 +1,625 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +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; + + public QwpResultBatchDecoder(QwpEgressConnState state) + { + _state = state; + } + + 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 stagedSchemaId = (ulong?)null; + EgressSchema? stagedSchema = null; + 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, out stagedSchemaId, out stagedSchema); + + if (p != payload.Length) + { + throw new QwpDecodeException($"trailing bytes after RESULT_BATCH: consumed {p}, payload {payload.Length}"); + } + + // Inside try so a RegisterSchema throw still rewinds the symbol dict. + if (stagedSchemaId is { } id && stagedSchema is { } sc) + { + _state.RegisterSchema(id, sc); + } + commit = true; + } + finally + { + if (!commit) + { + // Symbols already appended into the dict; rewind to the pre-batch cursor on failure. + _state.SymbolDict.TruncateTo(preDictSize); + } + } + } + + 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, + out ulong? stagedSchemaId, out EgressSchema? stagedSchema) + { + stagedSchemaId = null; + stagedSchema = null; + + 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"); + var colCount = ReadBoundedVarintAsInt(payload, ref p, "col_count"); + if (rowCount > QwpConstants.MaxRowsPerTable) + { + throw new QwpDecodeException($"row_count out of range: {rowCount}"); + } + + if (colCount > QwpConstants.MaxColumnsPerTable) + { + throw new QwpDecodeException($"col_count out of range: {colCount}"); + } + + if (p >= payload.Length) + { + throw new QwpDecodeException("truncated before schema_mode"); + } + var schemaMode = payload[p++]; + var schemaId = ReadVarint(payload, ref p); + if (schemaId >= (ulong)QwpConstants.MaxSchemasPerConnection) + { + throw new QwpDecodeException($"schema_id {schemaId} exceeds {QwpConstants.MaxSchemasPerConnection}"); + } + + EgressSchema schema; + if (schemaMode == QwpConstants.SchemaModeFull) + { + 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"); + } + var name = StrictUtf8.GetString(payload.Slice(p, cnLen)); + 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); + stagedSchemaId = schemaId; + stagedSchema = schema; + } + else if (schemaMode == QwpConstants.SchemaModeReference) + { + if (!_state.TryGetSchema(schemaId, out schema)) + { + throw new QwpDecodeException($"unknown schema_id {schemaId} in REFERENCE mode"); + } + if (schema.Columns.Length != colCount) + { + throw new QwpDecodeException( + $"schema_id {schemaId} has {schema.Columns.Length} cols but RESULT_BATCH says {colCount}"); + } + } + else + { + throw new QwpDecodeException($"unknown schema_mode 0x{schemaMode:X2}"); + } + + 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. + var consumed = QwpGorilla.DecodeToBytes(payload.Slice(p), col.ValueBytes.AsSpan(0, rawBytes), nonNull); + 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 = (int)ReadVarint(payload, ref p); + 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++]; + var maxScale = valueBytes switch + { + 8 => QwpConstants.MaxDecimal64Scale, + 16 => QwpConstants.MaxDecimal128Scale, + 32 => QwpConstants.MaxDecimal256Scale, + _ => byte.MaxValue, + }; + if (scale > maxScale) + { + throw new QwpDecodeException( + $"decimal scale {scale} exceeds the wire-format max {maxScale} for {valueBytes}-byte decimals"); + } + 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; + } + } + + private byte[] RentScratch(byte[] existing, int needed) + { + if (existing.Length >= needed) return existing; + var cap = Math.Max(needed, Math.Max(64, existing.Length * 2)); + var fresh = System.Buffers.ArrayPool.Shared.Rent(cap); + if (existing.Length > 0) + { + System.Buffers.ArrayPool.Shared.Return(existing); + } + return fresh; + } + + 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"); + } + var v = QwpVarint.Read(payload.Slice(p), out var consumed); + 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; + } + 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 0000000..9196ffc --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpRoleMismatchException.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. + * + ******************************************************************************/ + +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; + } + + /// 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 0000000..985bf2a --- /dev/null +++ b/src/net-questdb-client/Qwp/Query/QwpServerInfo.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. + * + ******************************************************************************/ + +namespace QuestDB.Qwp.Query; + +/// Decoded SERVER_INFO frame (v2 only). +public sealed class QwpServerInfo +{ + /// Server role byte; see QwpConstants.Role* for the defined values. + public byte 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; + + /// Human-readable name of ; UNKNOWN(n) for unrecognised codes. + public string RoleName => Role switch + { + QwpConstants.RoleStandalone => "STANDALONE", + QwpConstants.RolePrimary => "PRIMARY", + QwpConstants.RoleReplica => "REPLICA", + QwpConstants.RolePrimaryCatchup => "PRIMARY_CATCHUP", + _ => $"UNKNOWN({Role})", + }; +} diff --git a/src/net-questdb-client/Qwp/QwpBitWriter.cs b/src/net-questdb-client/Qwp/QwpBitWriter.cs new file mode 100644 index 0000000..8fbaa2c --- /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 (specification §12). +/// +/// +/// 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 0000000..44d6979 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpColumn.cs @@ -0,0 +1,900 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + + /// + /// 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) + { + EnsureBitmapCapacity(initialNullRows); + 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; + + /// 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 (per spec §10). + 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) + { + 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; + } + + /// + /// 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); + } + + private void AppendDecimalAtSize(QwpTypeCode code, decimal value, int sizeBytes) + { + AssertOrSetType(code); + + 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); + + byte targetScale; + if (!DecimalScaleSet) + { + targetScale = scale; + DecimalScale = scale; + DecimalScaleSet = true; + } + else + { + targetScale = DecimalScale; + } + + var mantissa = (new BigInteger((uint)bits[2]) << 64) + | (new BigInteger((uint)bits[1]) << 32) + | new BigInteger((uint)bits[0]); + + if (scale != targetScale) + { + if (scale < targetScale) + { + mantissa *= BigInteger.Pow(10, targetScale - scale); + } + else + { + var divisor = BigInteger.Pow(10, scale - targetScale); + if (!(mantissa % divisor).IsZero) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' decimal value {value} cannot be losslessly represented at scale {targetScale}"); + } + mantissa /= divisor; + } + } + + if (negative) mantissa = -mantissa; + + EnsureFixedCapacity(FixedLen + sizeBytes); + var dest = FixedData.AsSpan(FixedLen, sizeBytes); + WriteSignedDecimalAtSize(dest, mantissa, value, sizeBytes); + FixedLen += sizeBytes; + AdvanceNonNull(); + } + + private void WriteSignedDecimalAtSize(Span dest, BigInteger value, decimal source, int sizeBytes) + { + var bytes = value.ToByteArray(isUnsigned: false, isBigEndian: false); + if (bytes.Length > sizeBytes) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' decimal value {source} at scale {DecimalScale} overflows {sizeBytes * 8}-bit decimal range"); + } + var fill = value.Sign < 0 ? (byte)0xFF : (byte)0x00; + dest.Fill(fill); + bytes.AsSpan().CopyTo(dest); + } + + /// 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. The value must be non-negative and fit in 256 bits unsigned. + /// + public void AppendLong256(BigInteger value) + { + AssertOrSetType(QwpTypeCode.Long256); + + if (value.Sign < 0) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' Long256 values must be non-negative"); + } + + EnsureFixedCapacity(FixedLen + QwpConstants.Long256SizeBytes); + var dest = FixedData.AsSpan(FixedLen, QwpConstants.Long256SizeBytes); + + // Write unsigned LE bytes directly into the destination span; no per-row byte[] alloc. + if (!value.TryWriteBytes(dest, out var bytesWritten, isUnsigned: true, isBigEndian: false)) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"column '{Name}' Long256 value exceeds 256 bits ({value.GetByteCount(isUnsigned: true) * 8} bits supplied)"); + } + + 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) + { + var newSize = FixedData.Length; + while (newSize < required) + { + newSize *= 2; + } + + Array.Resize(ref FixedData, newSize); + } + } + + 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) + { + var newSize = BoolData.Length; + while (newSize < requiredBytes) + { + newSize *= 2; + } + + Array.Resize(ref BoolData, newSize); + } + } + + private void EnsureStringCapacity(int required) + { + if (StrData is null) + { + StrData = new byte[Math.Max(InitialStringCapacity, required)]; + return; + } + + if (StrData.Length < required) + { + var newSize = StrData.Length; + while (newSize < required) + { + newSize *= 2; + } + + Array.Resize(ref StrData, newSize); + } + } + + private void EnsureOffsetCapacity(int requiredCount) + { + if (StrOffsets is null) + { + StrOffsets = new uint[Math.Max(InitialSymbolCapacity, requiredCount)]; + StrOffsets[0] = 0; + return; + } + + if (StrOffsets.Length < requiredCount) + { + var newSize = StrOffsets.Length; + while (newSize < requiredCount) + { + newSize *= 2; + } + + Array.Resize(ref StrOffsets, newSize); + } + } + + private void GrowSymbolArray() + { + if (SymbolIds is null) + { + SymbolIds = new int[InitialSymbolCapacity]; + return; + } + + Array.Resize(ref SymbolIds, SymbolIds.Length * 2); + } + + 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) + { + var newSize = NullBitmap.Length; + while (newSize < requiredBytes) + { + newSize *= 2; + } + + Array.Resize(ref NullBitmap, newSize); + } + } + + 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) + { + var newSize = NullBitmap.Length; + while (newSize < requiredBytes) + { + newSize *= 2; + } + + Array.Resize(ref NullBitmap, newSize); + } + } + + NullBitmap[rowIndex >> 3] |= (byte)(1 << (rowIndex & 7)); + } +} diff --git a/src/net-questdb-client/Qwp/QwpConstants.cs b/src/net-questdb-client/Qwp/QwpConstants.cs new file mode 100644 index 0000000..3206473 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpConstants.cs @@ -0,0 +1,241 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 only ingest protocol version this client speaks. + public const byte SupportedIngestVersion = 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; + + /// Schema-id followed by inline column definitions. + public const byte SchemaModeFull = 0x00; + + /// Schema-id only; the server resolves columns from its registry. + public const byte SchemaModeReference = 0x01; + + /// Size of an OK response without per-table entries: 1-byte status + 8-byte sequence. + public const int OkAckMinSize = 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. + public const byte ResetMaskDict = 0x01; + public const byte ResetMaskSchemas = 0x02; + + /// SERVER_INFO role bytes. + public const byte RoleStandalone = 0x00; + public const byte RolePrimary = 0x01; + public const byte RoleReplica = 0x02; + public const byte RolePrimaryCatchup = 0x03; + + /// 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; + + /// + /// Server cap is MAX_COLUMNS_PER_TABLE = 2048; the egress spec doc §16 quotes 1024 + /// but is stale relative to the server. + /// + public const int MaxBindParameters = 2048; + + 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; + public const int SchemaRegistrySoftCap = 4096; + + /// 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; + public const int MaxSchemasPerConnection = 65_535; + + /// Server-side zstd level clamp. Client may send any level; server rounds. + public const int ZstdLevelMin = 1; + public const int ZstdLevelMax = 22; + + /// 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"; + + /// QWP egress version handed out by Phase-1 servers. + public const byte SupportedEgressVersion = 0x02; + + /// Default auto-flush threshold by row count. + public const int DefaultAutoFlushRows = 1000; + + /// Default auto-flush interval, in milliseconds. + public const int DefaultAutoFlushIntervalMs = 100; + + /// Default cap on per-connection schema slots; matches the wire schema-id range. + public const int DefaultMaxSchemasPerConnection = 65535; + + /// 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"; + + 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"; +} diff --git a/src/net-questdb-client/Qwp/QwpEncoder.cs b/src/net-questdb-client/Qwp/QwpEncoder.cs new file mode 100644 index 0000000..c2646f1 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpEncoder.cs @@ -0,0 +1,542 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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 . +/// +/// FLAG_DELTA_SYMBOL_DICT is always set; symbol columns reference connection-global ids +/// and the prelude carries only the delta since the last successful flush. When a frame +/// contains no symbol columns, the delta is empty (0x00 0x00) but the prelude is still +/// written. +/// +/// FLAG_GORILLA is set when gorillaEnabled is requested. Each TIMESTAMP / +/// TIMESTAMP_NANOS column body is then 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 and schema cache but does not advance their +/// committed watermarks. The caller (QwpWebSocketSender) must call +/// after a successful flush. +/// +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. + /// Per-connection schema id allocator and reuse cache. + /// Connection-global symbol dictionary; only the delta is emitted. + /// + /// If true, the frame emits every table's schema in full mode and the symbol delta + /// 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. Defaults to false. + /// + /// + /// If true, the frame's flags byte carries FLAG_GORILLA and every TIMESTAMP / + /// TIMESTAMP_NANOS column is preceded by an encoding_flag byte. The encoder + /// transparently falls back to uncompressed per column when DoDs overflow int32. + /// 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, + QwpSchemaCache schemaCache, + QwpSymbolDictionary symbolDictionary, + bool selfSufficient = false, + bool gorillaEnabled = false) + { + var builder = new FrameBuilder(InitialCapacity); + var len = EncodeInto(builder, tables, schemaCache, symbolDictionary, selfSufficient, gorillaEnabled); + 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, + QwpSchemaCache schemaCache, + QwpSymbolDictionary symbolDictionary, + bool selfSufficient = false, + bool gorillaEnabled = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(tables); + ArgumentNullException.ThrowIfNull(schemaCache); + 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); + + var emittedTableCount = 0; + for (var i = 0; i < tables.Count; i++) + { + var t = tables[i]; + if (t.RowCount == 0 || t.TotalColumnCount == 0) + { + continue; + } + + var localSchemaId = selfSufficient ? emittedTableCount : -1; + WriteTableBlock(builder, t, schemaCache, selfSufficient, gorillaEnabled, localSchemaId); + 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.SupportedIngestVersion; + var flags = QwpConstants.FlagDeltaSymbolDict; + if (gorillaEnabled) + { + flags |= QwpConstants.FlagGorilla; + } + + header[QwpConstants.OffsetFlags] = flags; + 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) + { + var deltaStart = selfSufficient ? 0 : symbols.DeltaStart; + var deltaCount = selfSufficient ? symbols.Count : symbols.DeltaCount; + + 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, QwpSchemaCache schemaCache, bool selfSufficient, bool gorillaEnabled, int localSchemaId) + { + WriteString(buf, table.TableName); + buf.WriteVarint((ulong)table.RowCount); + buf.WriteVarint((ulong)table.TotalColumnCount); + + byte mode; + int schemaId; + if (selfSufficient) + { + // Frame-local id, FULL schema, no schemaCache mutation. Each SF frame is replayable + // standalone — no per-connection counter dependency. + schemaId = localSchemaId; + mode = QwpConstants.SchemaModeFull; + } + else + { + (mode, schemaId) = schemaCache.PrepareSchema(table); + } + + buf.WriteByte(mode); + buf.WriteVarint((ulong)schemaId); + + if (mode == QwpConstants.SchemaModeFull) + { + 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, gorillaEnabled); + } + + if (table.DesignatedTimestampColumn is not null) + { + WriteColumnData(buf, table.DesignatedTimestampColumn, table.RowCount, gorillaEnabled); + } + } + + 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, bool gorillaEnabled) + { + // 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 (gorillaEnabled && 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: + buf.WriteByte(col.DecimalScale); + return; + case QwpTypeCode.Geohash: + 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: + if (gorillaEnabled) + { + WriteTimestampColumnGorilla(buf, col, n); + } + else + { + buf.WriteBytes(col.FixedData!.AsSpan(0, col.FixedLen)); + } + + 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 0000000..e096f64 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpException.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. + * + ******************************************************************************/ + +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; + } + + /// 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 0000000..229bedf --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpGorilla.cs @@ -0,0 +1,402 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 (specification §12). +/// +/// +/// 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}"); + } + + 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 (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 0000000..fe2622b --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpHostHealthTracker.cs @@ -0,0 +1,203 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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, +} + +/// +/// Per-sender bookkeeping that ranks the configured addr= list when +/// selecting the next endpoint to try. Classifications are populated from +/// the outcome of each connect attempt: a 421 + X-QuestDB-Role: +/// PRIMARY_CATCHUP reject becomes , +/// a REPLICA reject becomes , +/// any other transport failure becomes , +/// and a successful upgrade becomes . +/// +/// Within a round, returns the highest-priority host +/// that has not yet been attempted. The caller calls +/// to clear the attempted-this-round flags either keeping classifications +/// (sticky-recovery after a previously-healthy connection drops) or +/// discarding them (re-evaluate after backoff completes a failed round). +/// +/// +internal sealed class QwpHostHealthTracker +{ + private static readonly QwpHostState[] PriorityOrder = + { + QwpHostState.Healthy, + QwpHostState.Unknown, + QwpHostState.TransientReject, + QwpHostState.TransportError, + QwpHostState.TopologyReject, + }; + + // Shared between the SF reconnect thread and drainer-pool tasks; guards _states + _attemptedThisRound. + private readonly object _lock = new(); + private readonly bool[] _attemptedThisRound; + private readonly string[] _hosts; + private readonly QwpHostState[] _states; + private readonly long[] _lastSuccessEpoch; + private long _successCounter; + + public QwpHostHealthTracker(IReadOnlyList hosts) + { + 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]; + } + + 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]; + } + + /// Returns the highest-priority host not yet attempted this round, or -1 when exhausted. + public int PickNext() + { + lock (_lock) + { + foreach (var priority in PriorityOrder) + { + for (var i = 0; i < _hosts.Length; i++) + { + if (!_attemptedThisRound[i] && _states[i] == priority) 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; + } + } + } + + /// + /// Clears the attempted-this-round flags. With , + /// every host except the last-known entry is reset + /// to — the sticky-Healthy keeps the previously-good + /// host first in line on the next round while letting the rest re-evaluate. + /// + 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 && _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 0000000..82b09d1 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpIngressRoleRejectedException.cs @@ -0,0 +1,68 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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, Exception? innerException = null) + : base(ErrorCode.SocketError, + $"WebSocket ingress upgrade rejected by role={role} at {uri}", + innerException) + { + Role = role; + Uri = uri; + } + + /// 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; } + + /// + /// 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/QwpResponse.cs b/src/net-questdb-client/Qwp/QwpResponse.cs new file mode 100644 index 0000000..7c5475b --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpResponse.cs @@ -0,0 +1,279 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 (legacy) — 9 bytes: uint8 status (0x00) + int64 sequence. +/// The sequence is the cumulative ACK watermark; every batch with seq ≤ +/// has succeeded. is empty. +/// +/// +/// OK (with per-table seqTxns) — same prefix as legacy, plus a uint16 tableCount +/// and tableCount repeating [uint16 nameLen + name + int64 seqTxn] entries. +/// Servers send this shape when the client opted in via request_durable_ack=on. +/// +/// +/// 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 (capped at 1024 bytes). +/// +/// +/// +/// 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); + + 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 legacy 9-byte OK frames 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 (!IsKnownErrorStatus(status)) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP response carries unknown status code 0x{statusByte:X2}"); + } + + return ParseError(status, frame); + } + + private static QwpResponse ParseOk(ReadOnlySpan frame) + { + // Spec: status (1) + sequence (8) + tableCount (2) + entries. Minimum 11 bytes; matches Java. + const int headerSize = QwpConstants.OkAckMinSize + 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.OkAckMinSize, 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)); + + if (msgLen > QwpConstants.MaxErrorMessageBytes) + { + throw new IngressError(ErrorCode.ProtocolVersionError, + $"QWP error message length {msgLen} exceeds the {QwpConstants.MaxErrorMessageBytes}-byte cap"); + } + + 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}"); + } + + // Lenient on the user-visible error string so a buggy server can't crash the client mid-error. + var message = msgLen == 0 + ? string.Empty + : Encoding.UTF8.GetString(frame.Slice(QwpConstants.ErrorAckHeaderSize, msgLen)); + + 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; + } + + private static bool IsKnownErrorStatus(QwpStatusCode status) + { + return status is QwpStatusCode.SchemaMismatch + or QwpStatusCode.ParseError + or QwpStatusCode.InternalError + or QwpStatusCode.SecurityError + or QwpStatusCode.WriteError; + } +} diff --git a/src/net-questdb-client/Qwp/QwpSchemaCache.cs b/src/net-questdb-client/Qwp/QwpSchemaCache.cs new file mode 100644 index 0000000..8ef0e6c --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpSchemaCache.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 QuestDB.Enums; +using QuestDB.Utils; + +namespace QuestDB.Qwp; + +/// +/// Connection-scoped schema id allocator. +/// +/// +/// Per we cache the schema id assigned by this allocator. Schema +/// ids are simple counters; no content hash. The server registers schemas by the id we send, +/// not by their definition equality, so collisions are not a concern. +/// +/// A table with means +/// the full schema has already gone on the wire on this connection: subsequent frames may +/// reference it by id. A table with < 0 (sentinel) +/// means "not yet allocated" — the encoder picks the next id, emits the schema in +/// mode, and updates the watermark. +/// +/// Once a schema goes on the wire, it stays there for the rest of the connection. Connection +/// reset (terminal failure, reconnect) calls . +/// +/// Invariant. The watermark is bumped at time (encode), not +/// at server-ACK time. This is safe under the sender's terminal-on-error contract: a server NACK +/// (any non-OK status) drops the connection and resets the cache; SF replay uses the +/// self-sufficient encoder mode that always emits FULL. So maxSent tracks "what the +/// server has registered for this connection" because anything sent has either been ACKed or has +/// poisoned the connection. Removing the terminal-on-error contract would require switching to a +/// per-batch watermark promoted on ACK (matches Go's batchMaxSchemaId). +/// +internal sealed class QwpSchemaCache +{ + /// Sentinel for "no schema id assigned to this table yet". + public const int UnassignedSchemaId = -1; + + private readonly int _maxSchemasPerConnection; + + private int _nextSchemaId; + private int _maxSentSchemaId = UnassignedSchemaId; + + public QwpSchemaCache(int maxSchemasPerConnection = QwpConstants.DefaultMaxSchemasPerConnection) + { + if (maxSchemasPerConnection < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxSchemasPerConnection)); + } + + _maxSchemasPerConnection = maxSchemasPerConnection; + } + + /// Highest schema id that has gone on the wire so far. + public int MaxSentSchemaId => _maxSentSchemaId; + + /// Number of schema ids allocated so far. + public int AllocatedCount => _nextSchemaId; + + /// + /// Decides which schema mode to emit for the table and, if needed, allocates a fresh id. + /// + /// The schema id to write on the wire, and whether to send the full definition. + /// When the per-connection limit is exhausted. + public (byte Mode, int SchemaId) PrepareSchema(QwpTableBuffer table) + { + ArgumentNullException.ThrowIfNull(table); + + if (table.SchemaId == UnassignedSchemaId || table.SchemaId > _maxSentSchemaId) + { + if (_nextSchemaId >= _maxSchemasPerConnection) + { + throw new IngressError(ErrorCode.InvalidApiCall, + $"max_schemas_per_connection={_maxSchemasPerConnection} exhausted; close and recreate the sender"); + } + + table.SchemaId = _nextSchemaId++; + _maxSentSchemaId = table.SchemaId; + return (QwpConstants.SchemaModeFull, table.SchemaId); + } + + return (QwpConstants.SchemaModeReference, table.SchemaId); + } + + /// Clears all state. Called on connection reset. + public void Reset() + { + _nextSchemaId = 0; + _maxSentSchemaId = UnassignedSchemaId; + } +} diff --git a/src/net-questdb-client/Qwp/QwpSymbolDictionary.cs b/src/net-questdb-client/Qwp/QwpSymbolDictionary.cs new file mode 100644 index 0000000..f3bb351 --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpSymbolDictionary.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. + * + ******************************************************************************/ + +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, per spec §7 (delta dictionary section). 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) + { + if (value.IsEmpty) + { + throw new IngressError(ErrorCode.InvalidApiCall, "symbol value must not be empty"); + } + 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 + + 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 0000000..143cc2e --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpTableBuffer.cs @@ -0,0 +1,527 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/// +/// Per-table columnar buffer used by the WebSocket sender. +/// +/// +/// Owns an ordered list of instances, the table's row count, and the +/// schema id slot used for the cached vs. full schema decision. +/// +/// 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. +/// +/// +/// A of -1 means "needs allocation on next flush". The id is reset +/// to -1 whenever a new column is added, forcing the next frame to send the schema in +/// full mode. +/// +internal sealed class QwpTableBuffer +{ + private readonly Dictionary _columnIndex = new(StringComparer.Ordinal); +#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 int _committedSchemaId = -1; + 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"); + } + + var nameByteCount = Encoding.UTF8.GetByteCount(tableName); + 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; } + + /// + /// Per-connection schema id; -1 means "not yet allocated". The encoder assigns + /// a fresh id on the next flush when this is -1, and emits the schema in full mode + /// (otherwise reference mode is used). + /// + public int SchemaId { get; internal set; } = -1; + + /// + /// 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; } + + /// 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 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, columns, and schema id. Used by the + /// sender to recycle the buffer between batches without losing the cached schema id — + /// subsequent encodes will emit . + /// + 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; + _committedSchemaId = SchemaId; + _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. + /// + /// + /// A new column resets to -1, which forces the next encoded frame to + /// emit a fresh full-schema block. 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 + + var nameByteCount = Encoding.UTF8.GetByteCount(columnName); + 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; + SchemaId = -1; + + 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); + } + + SchemaId = _committedSchemaId; + + 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); + SchemaId = -1; + _designatedCreatedInCurrentRow = true; + } + + return DesignatedTimestampColumn; + } + + private void FinaliseRow() + { + for (var i = 0; i < _columns.Count; i++) + { + if (!_touchedInCurrentRow[i]) + { + _columns[i].AppendNull(); + } + + _touchedInCurrentRow[i] = false; + } + + RowCount++; + HasPendingRow = false; + _committedColumnCount = _columns.Count; + _committedSchemaId = SchemaId; + _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 0000000..cd65c8b --- /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 0000000..28f39cd --- /dev/null +++ b/src/net-questdb-client/Qwp/QwpWebSocketTransport.cs @@ -0,0 +1,474 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 = 0: we manage connection liveness via QWP ACK timeouts in the +/// sender; built-in WebSocket pings would only complicate that. +/// +/// 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 = new(); + private readonly object _dumpLock = new(); + + private bool _disposed; + private int _negotiatedVersion; + + /// 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; + + var ws = _client.Options; + ws.KeepAliveInterval = TimeSpan.Zero; + 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; } + + /// + /// 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 IngressError(ErrorCode.AuthError, + $"WebSocket upgrade rejected with HTTP {status} for {_options.Uri}", ex); + } + + if (status == 421) + { + var role = ReadOptionalHeader(QwpConstants.HeaderQuestDbRole); + if (!string.IsNullOrEmpty(role)) + { + throw new QwpIngressRoleRejectedException(role!, _options.Uri!, ex); + } + } + + throw new IngressError(ErrorCode.SocketError, $"failed to connect to {_options.Uri}", ex); + } + + _negotiatedVersion = ReadNegotiatedVersion(); + if (_negotiatedVersion < 1 || _negotiatedVersion > _options.ClientMaxVersion) + { + await TryCloseAsync(WebSocketCloseStatus.ProtocolError, "unsupported QWP version", ct) + .ConfigureAwait(false); + throw new IngressError( + ErrorCode.ProtocolVersionError, + $"server negotiated QWP version {_negotiatedVersion}; this client supports v1..v{_options.ClientMaxVersion}"); + } + NegotiatedContentEncoding = ReadOptionalHeader(QwpConstants.HeaderContentEncoding); + } + + 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)) return v; + } + 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) + { + var ec = _client.CloseStatus == WebSocketCloseStatus.PolicyViolation + ? ErrorCode.AuthError + : ErrorCode.SocketError; + throw new IngressError( + ec, + $"server closed the WebSocket: {_client.CloseStatus} {_client.CloseStatusDescription}"); + } + + 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) + { + var ec = _client.CloseStatus == WebSocketCloseStatus.PolicyViolation + ? ErrorCode.AuthError + : ErrorCode.SocketError; + throw new IngressError( + ec, + $"server closed the WebSocket: {_client.CloseStatus} {_client.CloseStatusDescription}"); + } + + 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); + } + } + } + + /// + /// 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 (_disposed) + { + return; + } + + await TryCloseAsync(status, description, ct).ConfigureAwait(false); + } + + Task IQwpCursorTransport.CloseAsync(CancellationToken cancellationToken) => + CloseAsync(ct: cancellationToken); + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + 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() + { + // The version header proves we're talking to a QWP server, not an arbitrary WS service. + var headers = _client.HttpResponseHeaders; + if (headers is null || !headers.TryGetValue(QwpConstants.HeaderVersion, out var values)) + { + throw new IngressError( + ErrorCode.ProtocolVersionError, + $"server did not return a {QwpConstants.HeaderVersion} header on the upgrade response; " + + "endpoint is not a QWP server"); + } + + 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 (_disposed) + { + 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; } + + /// 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.SupportedIngestVersion; + + /// 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 0000000..487d765 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/IQwpCursorTransport.cs @@ -0,0 +1,55 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 +{ + /// 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); + + /// 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 0000000..3b108a8 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/IQwpSegment.cs @@ -0,0 +1,51 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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). +/// +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 0000000..5d3fc5b --- /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/QwpBackgroundDrainer.cs b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainer.cs new file mode 100644 index 0000000..e7daf22 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainer.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. + * + ******************************************************************************/ + +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; + + // 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) + { + 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; + } + + public QwpBackgroundDrainer( + Func transportFactory, + QwpReconnectPolicy reconnectPolicy, + long segmentCapacity, + TimeSpan drainTimeout, + Func? skipBackoffPredicate = null) + : this( + () => new DrainContext(transportFactory, skipBackoffPredicate), + reconnectPolicy, + segmentCapacity, + drainTimeout) + { + ArgumentNullException.ThrowIfNull(transportFactory); + } + + public async Task DrainAsync(string slotDirectory, CancellationToken cancellationToken) + { + var ctx = _contextBuilder(); + var ring = QwpSegmentRing.Open(slotDirectory, segmentCapacity: _segmentCapacity); + QwpCursorSendEngine? engine = null; + try + { + // 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); + + 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(); + } + } + } +} + +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 0000000..203ea7f --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpBackgroundDrainerPool.cs @@ -0,0 +1,287 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + +/// +/// 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 terminal failure, 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 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"; + + private readonly IQwpSlotDrainer _drainer; + private readonly SemaphoreSlim _slots; + private readonly object _trackingLock = new(); + private readonly List _runningTasks = new(); + private readonly HashSet _liveLocks = 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 ?? TimeSpan.FromSeconds(5); + } + 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); + } + + // 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); + } + }, 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(TimeSpan.FromSeconds(2)); + } + 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); + } + + // On wedge, leak _shutdownCts and _slots so a still-running drainer can't NRE on them. + 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 (IsReplayImpossible(ex)) + { + TryDropFailedSentinel(slotLock.SlotDirectory, ex); + } + catch (Exception) + { + // Transient (timeout, transport, IO) — leave the slot for next attempt. + } + finally + { + try + { + _slots.Release(); + } + catch (ObjectDisposedException) + { + } + catch (SemaphoreFullException) + { + } + } + } + finally + { + lock (_trackingLock) + { + _liveLocks.Remove(slotLock); + } + slotLock.Dispose(); + } + } + + private static bool IsReplayImpossible(Exception ex) + { + if (ex is QwpException q) + { + return q.Status switch + { + Enums.QwpStatusCode.SchemaMismatch => true, + Enums.QwpStatusCode.SecurityError => true, + Enums.QwpStatusCode.ParseError => true, + _ => false, + }; + } + + return false; + } + + private const int FailedSentinelMaxBytes = 4096; + + private static void TryDropFailedSentinel(string slotDirectory, Exception ex) + { + try + { + var content = ex.ToString(); + if (content.Length > FailedSentinelMaxBytes) + { + content = content.Substring(0, FailedSentinelMaxBytes) + "\n... [truncated]"; + } + File.WriteAllText(Path.Combine(slotDirectory, 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 0000000..b74f254 --- /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 0000000..17025d6 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpCursorSendEngine.cs @@ -0,0 +1,1076 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + + 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 SenderErrorPolicyResolver? _policyResolver; + private readonly object _stateLock = new(); + private readonly byte[] _sendBuffer; + private readonly byte[] _ackBuffer; + + private long _cursorFsn; + private long _ackedFsn; + private long _sentFsnHighWatermark; + 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. + /// + 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) + { + 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; + _cursorFsn = ring.OldestFsn; + _ackedFsn = ring.OldestFsn; + _segmentManager = new QwpSegmentManager(ring, maxTotalBytes); + _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; + } + } + } + + /// 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) + { + } + } + } + + /// 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); + + if (fullyDrained && slotDir is not null) + { + UnlinkSegmentFiles(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 + { + 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 + { + try + { + await transport.ConnectAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (IngressError ex) when ( + ex.code is ErrorCode.AuthError or ErrorCode.ProtocolVersionError + && ex is not QwpIngressRoleRejectedException) + { + SetTerminal(ex); + return; + } + catch (QwpIngressRoleRejectedException ex) + { + backoff.ResetAttempt(); + backoff.OutageStartTickMs ??= Environment.TickCount64; + var elapsed = TimeSpan.FromMilliseconds( + Environment.TickCount64 - backoff.OutageStartTickMs.Value); + if (elapsed >= _reconnectPolicy.MaxOutageDuration) + { + SetTerminal(ex); + return; + } + + if (_skipBackoffPredicate?.Invoke() == true) + { + continue; + } + + var remaining = _reconnectPolicy.MaxOutageDuration - elapsed; + var sleep = remaining < _reconnectPolicy.InitialBackoff + ? remaining + : _reconnectPolicy.InitialBackoff; + try + { + await Task.Delay(sleep, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + elapsed = TimeSpan.FromMilliseconds( + Environment.TickCount64 - backoff.OutageStartTickMs.Value); + if (elapsed >= _reconnectPolicy.MaxOutageDuration) + { + SetTerminal(ex); + return; + } + continue; + } + catch (Exception ex) + { + if (_skipBackoffPredicate?.Invoke() == true) + { + continue; + } + + if (!_seenFirstConnect && _initialConnectMode == InitialConnectMode.off) + { + SetTerminal(ex); + return; + } + + if (!await BackoffOrGiveUpAsync(ex, backoff, ct).ConfigureAwait(false)) + { + return; + } + + continue; + } + + _seenFirstConnect = true; + backoff.Reset(); + FireFirstConnectSucceeded(); + + long fsnAtZero; + lock (_stateLock) + { + // Rewind cursor to first un-acked, clamped to the ring's oldest available FSN + // so we never rewind past frames already trimmed off the ring head. + _cursorFsn = Math.Max(_ackedFsn, _ring.OldestFsn); + fsnAtZero = _cursorFsn; + _sentFsnHighWatermark = _cursorFsn - 1; + } + + try + { + await RunConnectionAsync(transport, fsnAtZero, ct).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) + { + return; + } + catch (HaltCarrier hc) + { + SetTerminal(hc.Wire, hc.SenderError); + return; + } + catch (Exception ex) when (IsTerminalServerError(ex)) + { + SetTerminal(ex); + return; + } + catch (Exception ex) + { + 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) + { + } + + // Prefer a faulted task over WhenAny's winner: if recv faulted with QwpException and send + // completed via cancellation, IsTerminalServerError must see the recv exception. + var fault = sendTask.Exception?.GetBaseException() ?? recvTask.Exception?.GetBaseException(); + 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) + { + throw new IngressError( + ErrorCode.ServerFlushError, + $"internal: cursor at FSN {readFsn} fell out of segment range"); + } + + // Optimistic advance under lock: receive pump's ack-clamp must not lag. + _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); + } + } + + private async Task ReceivePumpAsync(IQwpCursorTransport transport, long fsnAtZero, CancellationToken ct) + { + var ackBuffer = _ackBuffer; + + while (!ct.IsCancellationRequested) + { + var ackLen = await transport.ReceiveFrameAsync(ackBuffer, ct).ConfigureAwait(false); + var response = QwpResponse.Parse(ackBuffer.AsSpan(0, ackLen)); + + if (!response.IsOk && !response.IsDurableAck) + { + HandleServerRejection(response, fsnAtZero); + continue; + } + + DispatchTableEntries(response); + + if (!response.IsOk) + { + continue; + } + + var ackedSeq = response.Sequence; + if (ackedSeq < 0) + { + throw new IngressError( + ErrorCode.ServerFlushError, + $"server returned negative ack sequence {ackedSeq}"); + } + + 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; + // Manager polls AckedFsn and trims off the I/O critical path. Acknowledge takes + // the highest acked FSN, so subtract 1 from our "first un-acked" semantics. + _ring.Acknowledge(_ackedFsn - 1); + FireAckSignalLocked(); + } + } + } + } + + private void HandleServerRejection(in QwpResponse response, long fsnAtZero) + { + var category = QwpErrorClassifier.Classify(response.Status); + var policy = QwpErrorClassifier.ResolvePolicy(category, _policyResolver); + + var wireSeq = response.Sequence; + 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.Max(0L, 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) + { + 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) + { + 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); + + /// + /// 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 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); + } + + // QwpException carries a server status code; per spec these are application-layer rejects + // that replay cannot fix. AuthError / ProtocolVersionError are likewise non-retryable. + private static bool IsTerminalServerError(Exception ex) + { + return ex is QwpException + || ex is HaltCarrier + || (ex is IngressError ie && ie.code is ErrorCode.AuthError or ErrorCode.ProtocolVersionError); + } + + 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 0000000..8dbfe81 --- /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/QwpFiles.cs b/src/net-questdb-client/Qwp/Sf/QwpFiles.cs new file mode 100644 index 0000000..1375326 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpFiles.cs @@ -0,0 +1,222 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; +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 without a recognisable HResult; the type check above + // already excludes specific subclasses, so plain IOException is the residual signal. + // Errno-based narrowing is unreliable cross-platform (EAGAIN/EWOULDBLOCK = 11 on Linux, + // 35 on BSD/macOS) — keep the broad fallback. + return ex.GetType() == typeof(IOException); + } + + /// + /// 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); + } + + // POSIX heuristic: NFS mounts typically live under /mnt or /net; not authoritative. + return false; + } +} 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 0000000..8c8b333 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpMemorySegment.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. + * + ******************************************************************************/ + +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; + + 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"); + } + + 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(); + } + return new QwpMemorySegment(ptr, capacity, baseFsn, maxFrameLength); + } + 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); + } + + 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 0000000..4020256 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpMmapSegment.cs @@ -0,0 +1,703 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.IO.MemoryMappedFiles; +using Microsoft.Win32.SafeHandles; + +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; + // Optimistic seal trailer at the file's last 16 bytes: lets recovery skip the O(n) envelope + // scan when a sealed segment was flushed cleanly. Format: u32 magic | u32 reserved | i64 offset. + public const int SealTrailerSize = 16; + public const uint SealTrailerMagic = 0x534C5453; + + 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"); + } + + 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 trustedEnd = TryReadSealTrailer(view, capacity); + var (writePos, offsets) = ScanForLastGoodEnvelope(view, capacity, maxFrameLength, trustedEnd); + 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. + 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; + } + + 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. Stamps the last-good-offset into + /// the segment's trailer so the next Open() can skip the per-envelope CRC walk. + /// + public void Seal() + { + if (IsSealed) + { + return; + } + + IsSealed = true; + WriteSealTrailer(_writePosition); + Flush(); + } + + private void WriteSealTrailer(long lastGoodOffset) + { + if (Capacity < SealTrailerSize) return; + if (lastGoodOffset > Capacity - SealTrailerSize) return; + + Span trailer = stackalloc byte[SealTrailerSize]; + BinaryPrimitives.WriteUInt32LittleEndian(trailer.Slice(0, 4), SealTrailerMagic); + BinaryPrimitives.WriteUInt32LittleEndian(trailer.Slice(4, 4), 0); + BinaryPrimitives.WriteInt64LittleEndian(trailer.Slice(8, 8), lastGoodOffset); + WriteSpan(Capacity - SealTrailerSize, trailer); + } + + private static long TryReadSealTrailer(MemoryMappedViewAccessor view, long capacity) + { + if (capacity < SealTrailerSize) + { + return -1L; + } + + Span trailer = stackalloc byte[SealTrailerSize]; + ViewToSpan(view, capacity - SealTrailerSize, trailer); + + var magic = BinaryPrimitives.ReadUInt32LittleEndian(trailer.Slice(0, 4)); + if (magic != SealTrailerMagic) + { + return -1L; + } + + var offset = BinaryPrimitives.ReadInt64LittleEndian(trailer.Slice(8, 8)); + if (offset < HeaderSize || offset > capacity - SealTrailerSize) + { + return -1L; + } + + return offset; + } + + /// 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; + Exception? flushError = null; + try + { + _view.Flush(); + _fileStream.Flush(flushToDisk: true); + } + catch (Exception ex) + { + flushError = ex; + } + + SfCleanup.Run(() => _handle.ReleasePointer()); + _view.Dispose(); + _mmap.Dispose(); + SfCleanup.Dispose(_fileStream); + + if (flushError is not null) + { + throw flushError; + } + } + + /// + /// Public test seam: replays the mmap and returns the last good offset and the table + /// of envelope start offsets. When is non-negative (set + /// by Open after a valid seal trailer), envelopes between HeaderSize and trustedEnd are + /// indexed without per-envelope CRC verification. + /// + internal static unsafe (long WritePosition, List Offsets) ScanForLastGoodEnvelope( + MemoryMappedViewAccessor view, + long capacity, + int maxFrameLength = DefaultMaxFrameLength, + long trustedEnd = -1L) + { + long offset = HeaderSize; + var offsets = new List(); + + var handle = view.SafeMemoryMappedViewHandle; + byte* basePtr = null; + handle.AcquirePointer(ref basePtr); + try + { + var skipCrc = trustedEnd >= HeaderSize; + var endLimit = skipCrc ? trustedEnd : capacity; + while (offset + EnvelopeHeaderSize <= endLimit) + { + 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 > endLimit) + { + break; + } + + if (!skipCrc) + { + 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(); + } + + // If we trusted the trailer and the envelope walk ended before reaching it, the trailer + // was lying — fall back to a full CRC scan from scratch. + if (trustedEnd >= HeaderSize && offset != trustedEnd) + { + return ScanForLastGoodEnvelope(view, capacity, maxFrameLength, trustedEnd: -1L); + } + + return (offset, offsets); + } + + 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}"); + } + + return BinaryPrimitives.ReadInt64LittleEndian(hdr.Slice(8, 8)); + } + + 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 0000000..9388830 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpOrphanScanner.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. + * + ******************************************************************************/ + +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; + } + + if (QwpSlotLock.IsHolderHeartbeatFresh(slotDir)) + { + return; + } + + if (QwpSlotLock.IsHolderProcessAlive(slotDir)) + { + 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 0000000..83e993f --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpReconnectPolicy.cs @@ -0,0 +1,172 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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. +/// +/// +/// 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 + 1)); + 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 0000000..d9c2474 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSegmentManager.cs @@ -0,0 +1,301 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 HeartbeatInterval = 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 SemaphoreSlim _wakeup = new(0, 1); + private readonly CancellationTokenSource _cts = new(); + + private Task? _workerTask; + private long _committedBytes; + private bool _disposed; + private Exception? _lastServiceError; + private Action? _heartbeatCallback; + + public QwpSegmentManager(QwpSegmentRing ring, long maxTotalBytes, TimeSpan? shutdownWait = 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; + _committedBytes = ring.TotalCapacityBytes; + ring.SetMaxTotalBytes(maxTotalBytes); + } + catch + { + _wakeup.Dispose(); + _cts.Dispose(); + throw; + } + } + + public long CommittedBytes => Volatile.Read(ref _committedBytes); + public long MaxTotalBytes => _maxTotalBytes; + + public void SetHeartbeatCallback(Action? callback) + { + Volatile.Write(ref _heartbeatCallback, callback); + } + 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); + var prev = Volatile.Read(ref _committedBytes); + var committed = snapshot > prev ? snapshot : prev; + Volatile.Write(ref _committedBytes, committed); + + if (_ring.NeedsHotSpare()) + { + if (committed + _ring.SegmentCapacity <= _maxTotalBytes) + { + ProvisionHotSpare(); + } + } + + DrainAndDisposeTrimmable(); + _ring.FlushActive(); + try { Volatile.Read(ref _heartbeatCallback)?.Invoke(); } + catch (Exception ex) { Volatile.Write(ref _lastServiceError, ex); } + } + + 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); + fs.SetLength(capacity); + // Force block allocation so a producer mmap-write can't trigger SIGBUS / EFAULT later + // when the disk turns out to be full. + ReserveDiskBlocks(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 static void ReserveDiskBlocks(FileStream fs, long length) + { + var pageSize = QwpFiles.PageSize > 0 ? QwpFiles.PageSize : 4096; + Span zero = stackalloc byte[1]; + zero[0] = 0; + for (long offset = 0; offset < length; offset += pageSize) + { + fs.Position = offset; + fs.Write(zero); + } + + if (length > 0) + { + fs.Position = length - 1; + fs.Write(zero); + } + } + + 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 0000000..73405a2 --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSegmentRing.cs @@ -0,0 +1,697 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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 Action? _managerWakeup; + private Action? _spareInstalledCallback; + private Action? _spareAdoptionFailed; + private bool _wakeRequestedForActive; + private volatile bool _closed; + + 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); + + 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); + } + + 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)); + } + + 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; + } + + 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; + + // Defensive: cursor-pump and trim never overlap on the same FSN range, but a stale + // segment reference shouldn't crash a read. + try + { + return seg.TryReadFrame(offset.Value, destination, out _); + } + catch (ObjectDisposedException) + { + 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) { } + } + + /// + public void Dispose() + { + IQwpSegment? active; + List sealedSnapshot; + string? sparePath; + lock (_lock) + { + if (_closed) + { + return; + } + + _closed = true; + active = Volatile.Read(ref _active); + Volatile.Write(ref _active, null); + sparePath = _hotSparePath; + _hotSparePath = null; + sealedSnapshot = new List(_sealedSegments); + _sealedSegments.Clear(); + } + + 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 + } + } + } + + 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 realPath = Path.Combine(_directory!, BuildFileName(baseFsn)); + + 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 baseFsn) + { + return FilenamePrefix + baseFsn.ToString("x16", CultureInfo.InvariantCulture) + FilenameSuffix; + } + + 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 0000000..08e4eeb --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSenderErrorDispatcher.cs @@ -0,0 +1,111 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 SenderErrorHandler _handler; + private readonly CancellationTokenSource _shutdown = new(); + 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)); + _handler = handler ?? DefaultHandler; + _inbox = Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false, + }); + } + + public long DroppedNotifications => Volatile.Read(ref _dropped); + public long TotalDelivered => Volatile.Read(ref _delivered); + + public bool Offer(SenderError error) + { + if (Volatile.Read(ref _disposed) != 0) return false; + var written = _inbox.Writer.TryWrite(error); + if (!written) + { + Interlocked.Increment(ref _dropped); + return false; + } + if (Interlocked.CompareExchange(ref _started, 1, 0) == 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) { } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _inbox.Writer.TryComplete(); + try { _shutdown.Cancel(); } catch { } + 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 0000000..b9d0c7c --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpSlotLock.cs @@ -0,0 +1,248 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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.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"; + public static readonly TimeSpan HeartbeatStaleAfter = TimeSpan.FromMinutes(5); + + private readonly FileStream _file; + private readonly string _pidSidecarPath; + private readonly string _heartbeatPath; + private bool _disposed; + + private QwpSlotLock(string slotDirectory, string lockFilePath, string pidSidecarPath, FileStream file) + { + SlotDirectory = slotDirectory; + LockFilePath = lockFilePath; + _pidSidecarPath = pidSidecarPath; + _heartbeatPath = Path.Combine(slotDirectory, HeartbeatFileName); + _file = file; + RefreshHeartbeat(); + } + + /// Updates the slot's heartbeat file mtime. Best-effort. + public void RefreshHeartbeat() + { + try + { + using var fs = new FileStream(_heartbeatPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read); + fs.SetLength(0); + } + catch + { + } + } + + /// The slot directory we hold the lock for. + public string SlotDirectory { get; } + + /// Full path of the lock file. + public string LockFilePath { get; } + + /// + /// 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, pidPath, 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, pidPath, 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.ToString(), Encoding.ASCII); + } + 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; + } + } + + internal static int? TryReadHolderPid(string slotDirectory) + { + try + { + var pidPath = Path.Combine(slotDirectory, PidSidecarName); + if (!File.Exists(pidPath)) return null; + var s = File.ReadAllText(pidPath, Encoding.ASCII).Trim(); + return int.TryParse(s, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var pid) ? pid : null; + } + catch + { + return null; + } + } + + internal static bool IsHolderProcessAlive(string slotDirectory) + { + var pid = TryReadHolderPid(slotDirectory); + if (pid is null || pid <= 0) return false; + try + { + using var proc = Process.GetProcessById(pid.Value); + return !proc.HasExited; + } + catch + { + return false; + } + } + + /// + /// True when the slot has a heartbeat file whose mtime is within + /// . PID-reuse safe: even if the original sender's PID + /// was recycled into an unrelated process, a stale mtime makes the slot adoptable. + /// + internal static bool IsHolderHeartbeatFresh(string slotDirectory) + { + try + { + var path = Path.Combine(slotDirectory, HeartbeatFileName); + if (!File.Exists(path)) return false; + var mtime = File.GetLastWriteTimeUtc(path); + return DateTime.UtcNow - mtime < HeartbeatStaleAfter; + } + catch + { + return false; + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + _file.Dispose(); + } + catch (Exception) + { + } + + try + { + if (File.Exists(_pidSidecarPath)) File.Delete(_pidSidecarPath); + } + catch + { + } + + try + { + if (File.Exists(_heartbeatPath)) File.Delete(_heartbeatPath); + } + catch + { + } + } +} 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 0000000..6be55ea --- /dev/null +++ b/src/net-questdb-client/Qwp/Sf/QwpTrackedCursorTransport.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. + * + ******************************************************************************/ + +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 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) + { + _tracker.RecordRoleReject(_hostIndex, ex.IsTransient); + throw; + } + catch (IngressError ex) when (ex.code == ErrorCode.AuthError) + { + throw; + } + catch + { + _tracker.RecordTransportError(_hostIndex); + throw; + } + finally + { + timeoutCts?.Dispose(); + } + + _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 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 0000000..d007092 --- /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 9ae0148..f3269c5 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,34 @@ public static SenderOptions Configure(string confStr) { return new SenderOptions(confStr); } + +#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 Senders.IQwpWebSocketSender NewQwp(string confStr) + { + var sender = New(confStr); + return AsQwp(sender); + } + + /// + public static Senders.IQwpWebSocketSender NewQwp(SenderOptions options) + { + var sender = New(options); + return AsQwp(sender); + } + + private static Senders.IQwpWebSocketSender AsQwp(ISender sender) + { + if (sender is Senders.IQwpWebSocketSender qwp) return qwp; + sender.Dispose(); + throw new IngressError(ErrorCode.ConfigError, + "NewQwp requires a ws:: or wss:: connect string"); + } +#endif } \ No newline at end of file diff --git a/src/net-questdb-client/Senders/HttpSender.cs b/src/net-questdb-client/Senders/HttpSender.cs index bceb6cf..31e900f 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 @@ -130,6 +114,11 @@ private SocketsHttpHandler CreateHandler(string host) } else { + if (_trustRoot is null && Options.tls_roots is not null) + { + _trustRoot = new Lazy(() => QwpTlsAuth.LoadTrustRoot(Options.tls_roots!, Options.tls_roots_password)); + } + var trustRoot = _trustRoot; handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) => { @@ -138,24 +127,19 @@ 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!)); }; } - 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(); @@ -838,6 +822,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 0000000..0197f07 --- /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 0000000..c7207d0 --- /dev/null +++ b/src/net-questdb-client/Senders/IQwpWebSocketSender.cs @@ -0,0 +1,94 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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); + + /// Append a DECIMAL64 value to the named column (8-byte mantissa). First call locks the scale. + IQwpWebSocketSender ColumnDecimal64(ReadOnlySpan name, decimal value); + + /// Append a DECIMAL256 value to the named column (32-byte mantissa). First call locks the scale. + IQwpWebSocketSender ColumnDecimal256(ReadOnlySpan name, decimal value); + + /// 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); + + /// + /// 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; } + + /// + /// Total notifications delivered to the + /// user-supplied (or default) error_handler. SF mode only; 0 otherwise. + /// + long TotalErrorNotificationsDelivered { get; } +} diff --git a/src/net-questdb-client/Senders/ISender.cs b/src/net-questdb-client/Senders/ISender.cs index 9e6a8d1..03304fa 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); diff --git a/src/net-questdb-client/Senders/QwpWebSocketSender.cs b/src/net-questdb-client/Senders/QwpWebSocketSender.cs new file mode 100644 index 0000000..d1ad528 --- /dev/null +++ b/src/net-questdb-client/Senders/QwpWebSocketSender.cs @@ -0,0 +1,968 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 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 QwpSchemaCache _schemaCache; + 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 Dictionary _committedSeqTxn = new(StringComparer.Ordinal); + private readonly Dictionary _durableSeqTxn = new(StringComparer.Ordinal); + private readonly object _seqTxnLock = new(); + + private QwpTableBuffer? _currentTable; + private int _disposed; + private int _runningRowCount; + + 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}"); + } + + _schemaCache = new QwpSchemaCache(options.max_schemas_per_connection); + _symbolDictionary = new QwpSymbolDictionary(); +#if NET9_0_OR_GREATER + _tablesLookup = _tables.GetAlternateLookup>(); +#endif + _encoderBuffer = new QwpEncoder.FrameBuilder(EncoderInitialCapacity); + + (_slotLock, _engine, _drainerPool, _errorDispatcher) = BuildEngineStack(options); + _engine.SetTableEntryHandler(UpdateSeqTxnFromAck); + } + + private static (QwpSlotLock? slotLock, QwpCursorSendEngine engine, QwpBackgroundDrainerPool? pool, QwpSenderErrorDispatcher? dispatcher) + BuildEngineStack(SenderOptions options) + { + var sfMode = !string.IsNullOrEmpty(options.sf_dir); + QwpSlotLock? slotLock = null; + QwpSegmentRing? ring = null; + QwpCursorSendEngine? engine = null; + QwpBackgroundDrainerPool? pool = null; + QwpSenderErrorDispatcher? dispatcher = 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); + } + else + { + ring = QwpSegmentRing.OpenMemoryBacked(segmentCapacity: options.sf_max_bytes); + } + + var authHeader = BuildAuthHeader(options); + var certValidator = BuildCertificateValidator(options); + var tracker = new QwpHostHealthTracker(options.addresses); + var transportFactory = BuildHostRotatingFactory(options, tracker, authHeader, certValidator); + + 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); + + 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()); + + engine.Start(); + + if (options.initial_connect_mode != InitialConnectMode.async) + { + try + { + engine.FirstConnectTask.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + throw new IngressError(ErrorCode.SocketError, + $"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); + var drainerFactory = BuildHostRotatingFactory( + options, drainerTracker, authHeader, certValidator); + return new Qwp.Sf.DrainContext( + drainerFactory, + () => !drainerTracker.IsRoundExhausted); + }, + policy, + segmentCapacity: options.sf_max_bytes, + drainTimeout: options.reconnect_max_duration_millis); + pool = new QwpBackgroundDrainerPool( + options.max_background_drainers, + drainer, + shutdownWait: options.close_flush_timeout_millis); + 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); + } + catch (Exception) + { + SfCleanup.Dispose(pool); + SfCleanup.Dispose(engine); + SfCleanup.Dispose(dispatcher); + SfCleanup.Dispose(ring); + SfCleanup.Dispose(slotLock); + throw; + } + } + + /// + public SenderOptions Options { get; } + + /// + public int Length + { + get + { + var total = 0; + foreach (var t in _tables.Values) + { + total += EstimateTableSize(t); + } + + return total; + } + } + + /// + public int RowCount => _runningRowCount; + + /// + public bool WithinTransaction => false; + + /// + public DateTime LastFlush { get; private set; } = DateTime.MinValue; + private long _lastFlushTickCount; + + /// + public ISender Transaction(ReadOnlySpan tableName) + { + throw new IngressError(ErrorCode.InvalidApiCall, "transactions are not supported on the WebSocket transport"); + } + + /// + public void Rollback() + { + throw new IngressError(ErrorCode.InvalidApiCall, "transactions are not supported on the WebSocket transport"); + } + + /// + public Task CommitAsync(CancellationToken ct = default) + { + throw new IngressError(ErrorCode.InvalidApiCall, "transactions are not supported on the WebSocket transport"); + } + + /// + public void Commit(CancellationToken ct = default) + { + throw new IngressError(ErrorCode.InvalidApiCall, "transactions are not supported on the WebSocket transport"); + } + + /// + 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; + return this; + } + + /// + public ISender Symbol(ReadOnlySpan name, ReadOnlySpan value) + { + ThrowIfTerminal(); + var preCount = _symbolDictionary.Count; + var globalId = _symbolDictionary.Add(value); + try + { + EnsureCurrentTable().AppendSymbol(name, globalId); + } + catch + { + // CancelCurrentRow rolls back column savepoints but not the dict. + if (_symbolDictionary.Count > preCount) + { + _symbolDictionary.RollbackTo(preCount); + } + 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) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal128(name, value); + return this; + } + + /// + 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; + } + + /// Appends a DECIMAL64 value (8-byte signed two's-complement mantissa). First non-null call locks the column scale. + public IQwpWebSocketSender ColumnDecimal64(ReadOnlySpan name, decimal value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal64(name, value); + return this; + } + + /// Appends a DECIMAL256 value (32-byte signed two's-complement mantissa). First non-null call locks the column scale. + public IQwpWebSocketSender ColumnDecimal256(ReadOnlySpan name, decimal value) + { + ThrowIfTerminal(); + EnsureCurrentTable().AppendDecimal256(name, value); + 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}`"); + } + + // IPAddress.TryWriteBytes writes network-byte-order octets (a.b.c.d → bytes a,b,c,d). + // Wire format is uint32 little-endian, where octet `a` is the LSB. + var packed = (uint)octets[0] | ((uint)octets[1] << 8) | ((uint)octets[2] << 16) | ((uint)octets[3] << 24); + EnsureCurrentTable().AppendIPv4(name, packed); + 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(); + EnsureCurrentTable().At(DateTimeToMicros(value)); + _runningRowCount++; + 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(); + EnsureCurrentTable().At(value); + _runningRowCount++; + return FlushIfNecessaryAsyncCore(ct); + } + + /// + public ValueTask AtNowAsync(CancellationToken ct = default) + => AtAsync(DateTime.UtcNow, ct); + + /// + public ValueTask AtNanosAsync(long timestampNanos, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + EnsureCurrentTable().AtNanos(timestampNanos); + _runningRowCount++; + return FlushIfNecessaryAsyncCore(ct); + } + + /// + public void At(DateTime value, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + EnsureCurrentTable().At(DateTimeToMicros(value)); + _runningRowCount++; + FlushIfNecessary(ct); + } + + /// + public void At(DateTimeOffset value, CancellationToken ct = default) + { + At(value.UtcDateTime, ct); + } + + /// + public void At(long value, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + EnsureCurrentTable().At(value); + _runningRowCount++; + FlushIfNecessary(ct); + } + + /// + public void AtNow(CancellationToken ct = default) + { + At(DateTime.UtcNow, ct); + } + + /// + public void AtNanos(long timestampNanos, CancellationToken ct = default) + { + ThrowIfTerminal(); + GuardLastFlushNotSet(); + EnsureCurrentTable().AtNanos(timestampNanos); + _runningRowCount++; + FlushIfNecessary(ct); + } + + /// + public Task SendAsync(CancellationToken ct = default) + { + ThrowIfTerminal(); + EnsureNoRowInProgress(); + return FlushAsyncCore(ct).AsTask(); + } + + /// + public void Send(CancellationToken ct = default) + { + ThrowIfTerminal(); + EnsureNoRowInProgress(); + FlushSync(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() + { + _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, _schemaCache, _symbolDictionary, + selfSufficient: true, + gorillaEnabled: Options.gorilla); + } + + private void FlushSync(CancellationToken ct) + { + var len = EncodeBatch(); + if (len == 0) return; + _engine.AppendBlocking(_encoderBuffer.AsSpan(0, len), ct); + OnFlushSucceeded(); + } + + private async ValueTask FlushAsyncCore(CancellationToken ct) + { + var len = EncodeBatch(); + if (len == 0) return; + await _engine.AppendAsync(_encoderBuffer.WrittenMemory, ct).ConfigureAwait(false); + OnFlushSucceeded(); + } + + private void OnFlushSucceeded() + { + _symbolDictionary.Reset(); + foreach (var t in _flushBatch) + { + t.SchemaId = -1; + t.Clear(); + } + + _flushBatch.Clear(); + _runningRowCount = 0; + _currentTable = null; + LastFlush = DateTime.UtcNow; + _lastFlushTickCount = Environment.TickCount64; + } + + /// + 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(); + } + + _currentTable = null; + _runningRowCount = 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 TotalErrorNotificationsDelivered => _errorDispatcher?.TotalDelivered ?? 0L; + + 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() + { + try + { + if (!_engine.IsTerminallyFailed && Options.close_flush_timeout_millis.TotalMilliseconds > 0) + { + FlushSync(CancellationToken.None); + _engine.FlushAsync(Options.close_flush_timeout_millis).GetAwaiter().GetResult(); + } + } + catch (Exception) + { + } + + SfCleanup.Dispose(_drainerPool); + SfCleanup.Dispose(_engine); + SfCleanup.Dispose(_errorDispatcher); + } + + private async ValueTask DisposeStackAsync() + { + try + { + if (!_engine.IsTerminallyFailed && Options.close_flush_timeout_millis.TotalMilliseconds > 0) + { + await FlushAsyncCore(CancellationToken.None).ConfigureAwait(false); + await _engine.FlushAsync(Options.close_flush_timeout_millis).ConfigureAwait(false); + } + } + catch (Exception) + { + } + + SfCleanup.Dispose(_drainerPool); + SfCleanup.Dispose(_engine); + SfCleanup.Dispose(_errorDispatcher); + } + + 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; + FlushSync(ct); + } + + private ValueTask FlushIfNecessaryAsyncCore(CancellationToken ct) + { + if (!ShouldAutoFlush()) return ValueTask.CompletedTask; + return FlushAsyncCore(ct); + } + + private bool ShouldAutoFlush() + { + if (Options.auto_flush != AutoFlushType.on) return false; + + var rowsTrigger = Options.auto_flush_rows > 0 && RowCount >= Options.auto_flush_rows; + var bytesTrigger = Options.auto_flush_bytes > 0 && Length >= Options.auto_flush_bytes; + var timeTrigger = Options.auto_flush_interval > TimeSpan.Zero + && Environment.TickCount64 - _lastFlushTickCount >= (long)Options.auto_flush_interval.TotalMilliseconds; + return rowsTrigger || bytesTrigger || timeTrigger; + } + + private static int EstimateTableSize(QwpTableBuffer t) + { + // Rough byte budget per table for auto_flush_bytes accounting. We don't recompute the + // exact wire size on every row — that would be O(N) per append. Sum FixedLen + StrLen + // over all columns instead, which is a tight upper bound on the row-data portion. + var total = 0; + foreach (var col in t.Columns) + { + total += col.FixedLen + col.StrLen; + } + + if (t.DesignatedTimestampColumn is not null) + { + total += t.DesignatedTimestampColumn.FixedLen + t.DesignatedTimestampColumn.StrLen; + } + + return total; + } + + private static long DateTimeToMicros(DateTime value) + { + var utc = value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value; + return (utc - DateTime.UnixEpoch).Ticks / TicksPerMicrosecond; + } + + 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 transportOpts = new QwpWebSocketTransportOptions + { + Uri = options.BuildUri(idx, QwpConstants.WritePath), + AuthorizationHeader = authHeader, + RequestDurableAck = options.request_durable_ack, + RemoteCertificateValidationCallback = certValidator, + Proxy = proxy, + }; + + 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, rawAuth: null); + + private static System.Net.Security.RemoteCertificateValidationCallback? BuildCertificateValidator(SenderOptions options) => + QwpTlsAuth.BuildCertificateValidator(options.tls_verify, options.tls_roots, options.tls_roots_password); +} + +#endif diff --git a/src/net-questdb-client/Senders/TcpSender.cs b/src/net-questdb-client/Senders/TcpSender.cs index eb8435c..90af149 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; diff --git a/src/net-questdb-client/Utils/AddressProvider.cs b/src/net-questdb-client/Utils/AddressProvider.cs index d0cc893..486d237 100644 --- a/src/net-questdb-client/Utils/AddressProvider.cs +++ b/src/net-questdb-client/Utils/AddressProvider.cs @@ -91,28 +91,12 @@ 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("[")) - { - var closingBracketIndex = address.IndexOf(']'); - if (closingBracketIndex > 0) - { - // Return the entire bracketed section as the host - return address.Substring(0, closingBracketIndex + 1); - } - } - - // For non-bracketed addresses, use the last colon to split host and port var colonIndex = address.LastIndexOf(':'); if (colonIndex > 0) { @@ -122,36 +106,12 @@ 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("[")) - { - 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; - } - - // For non-bracketed addresses, use the last colon to split host and port var colonIndex = address.LastIndexOf(':'); if (colonIndex >= 0 && colonIndex < address.Length - 1) { diff --git a/src/net-questdb-client/Utils/LineSenderServerException.cs b/src/net-questdb-client/Utils/LineSenderServerException.cs new file mode 100644 index 0000000..2bd7540 --- /dev/null +++ b/src/net-questdb-client/Utils/LineSenderServerException.cs @@ -0,0 +1,59 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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 +{ + 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 0000000..5a66209 --- /dev/null +++ b/src/net-questdb-client/Utils/QwpTlsAuth.cs @@ -0,0 +1,116 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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, string? rawAuth) + { + if (!string.IsNullOrEmpty(rawAuth)) + { + return rawAuth; + } + + 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; + } + + /// + /// Builds the for the TLS handshake. + /// Returns null when the system default chain validation is sufficient. + /// + public static RemoteCertificateValidationCallback? BuildCertificateValidator( + TlsVerifyType tlsVerify, + string? tlsRoots, + string? tlsRootsPassword) + { + if (tlsVerify == TlsVerifyType.unsafe_off) + { + return (_, _, _, _) => true; + } + + 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)); + return (_, certificate, chain, errors) => + { + if ((errors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0) + { + return false; + } + + using var serverCert = new X509Certificate2(certificate!); + chain!.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + if (chain.ChainPolicy.CustomTrustStore.Count == 0) + { + chain.ChainPolicy.CustomTrustStore.Add(trustRoot.Value); + } + return chain.Build(serverCert); + }; + } + + 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 0000000..4da819d --- /dev/null +++ b/src/net-questdb-client/Utils/SenderError.cs @@ -0,0 +1,145 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * 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; + + 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 af11799..cab7927 100644 --- a/src/net-questdb-client/Utils/SenderOptions.cs +++ b/src/net-questdb-client/Utils/SenderOptions.cs @@ -29,8 +29,10 @@ 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.Sf; using QuestDB.Senders; // ReSharper disable InconsistentNaming @@ -48,23 +50,18 @@ 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 _gzip; private int _initBufSize = 65536; private int _maxBufSize = 104857600; private int _maxNameLen = 127; @@ -74,18 +71,73 @@ 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 int _maxSchemasPerConnection = 65535; + private bool _requestDurableAck; + private bool _gorilla = true; + + 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); + 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(5000); + private bool _drainOrphans; + private int _maxBackgroundDrainers = 4; + private TimeSpan _pingTimeout = TimeSpan.FromMilliseconds(5000); + private string? _proxy; + private SenderErrorHandler? _errorHandler; + private SenderErrorPolicyResolver? _errorPolicyResolver; + private int _errorInboxCapacity = 256; + private SenderErrorPolicy? _onServerError; + private SenderErrorPolicy? _onSchemaMismatchError; + private SenderErrorPolicy? _onParseError; + private SenderErrorPolicy? _onInternalError; + private SenderErrorPolicy? _onSecurityError; + private SenderErrorPolicy? _onWriteError; + + private bool _maxSchemasPerConnectionUserSet; + private bool _requestDurableAckUserSet; + private bool _gorillaUserSet; + 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 _proxyUserSet; + private bool _errorHandlerUserSet; + private bool _errorPolicyResolverUserSet; + private bool _errorInboxCapacityUserSet; + 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,27 +158,452 @@ 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 + ? "0" + : int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture); + var defaultAutoFlushIntervalMs = isWs ? "100" : "1000"; + ParseIntThatMayBeOff(nameof(auto_flush_rows), defaultAutoFlushRows, out _autoFlushRows, + rejectLiteralZero: true); + var bytesProvided = ReadOptionFromBuilder(nameof(auto_flush_bytes)) is not null; + ParseIntThatMayBeOff(nameof(auto_flush_bytes), defaultAutoFlushBytes, out _autoFlushBytes); + if (isWs && !bytesProvided) _autoFlushBytes = 0; + 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); + } + 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); + + // WebSocket / QWP knobs. Parsed unconditionally; ValidateWebSocketKeys throws if any + // appear with a non-WebSocket scheme. + ParseIntWithDefault(nameof(max_schemas_per_connection), "65535", out _maxSchemasPerConnection); + ParseBoolOnOff(nameof(request_durable_ack), "off", out _requestDurableAck); + ParseBoolOnOff(nameof(gorilla), "on", out _gorilla); + + 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), "5000", out _closeFlushTimeout); + ParseBoolOnOff(nameof(drain_orphans), "off", out _drainOrphans); + ParseIntWithDefault(nameof(max_background_drainers), "4", out _maxBackgroundDrainers); + ParseMillisecondsWithDefault(nameof(ping_timeout), "5000", out _pingTimeout); + ParseStringWithDefault(nameof(proxy), null, out _proxy); + + _onServerError = ParsePolicyKey(nameof(on_server_error)); + _onSchemaMismatchError = ParsePolicyKey(nameof(on_schema_mismatch_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)); + + EnsureValid(); + } + + 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) + { + var raw = ReadOptionFromBuilder(name); + 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); + } + + 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()) + { + 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 (_reconnectInitialBackoff <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`reconnect_initial_backoff_millis` must be > 0; 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 (_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"); + if (_closeFlushTimeout <= TimeSpan.Zero) + throw new IngressError(ErrorCode.ConfigError, $"`close_flush_timeout_millis` must be > 0; got {_closeFlushTimeout.TotalMilliseconds}ms"); + } + } + + internal void EnsureValid() + { + ValidateAuthCombination(); + ValidateTlsCombination(); + ValidateMultiAddressForTcp(); + ValidateStoreAndForwardOptions(); + ValidateGzipForWebSocket(); + ValidateAutoFlushBytesForWebSocket(); + ValidateTimeouts(); + ValidateWebSocketKeys(); + ValidateWebSocketKeysAgainstDefaults(); + ValidateInitialConnectModeRequiresSf(); + ApplyAutoFlushNormalisation(); + } + + private void ValidateInitialConnectModeRequiresSf() + { + if (_errorInboxCapacity < 1) + { + throw new IngressError(ErrorCode.ConfigError, + $"`error_inbox_capacity` must be >= 1; got {_errorInboxCapacity}"); + } + } + + 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}"); + } + if (_sfMaxTotalBytes <= 0) + { + throw new IngressError(ErrorCode.ConfigError, + $"`sf_max_total_bytes` must be > 0; got {_sfMaxTotalBytes}"); + } + 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 (_maxSchemasPerConnectionUserSet) Throw(nameof(max_schemas_per_connection)); + if (_gorillaUserSet) Throw(nameof(gorilla)); + if (_requestDurableAckUserSet) Throw(nameof(request_durable_ack)); + 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 (_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 (_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 = 0; + 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 = + { + "max_schemas_per_connection", + "gorilla", "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", "initial_connect_mode", + "close_flush_timeout_millis", "drain_orphans", "max_background_drainers", "ping_timeout", "proxy", + "error_handler", "error_policy_resolver", "error_inbox_capacity", + "on_server_error", "on_schema_mismatch_error", "on_parse_error", "on_internal_error", + "on_security_error", "on_write_error", + }; + /// /// Protocol type for the sender to use. /// Defaults to . @@ -162,15 +639,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 + /// 503 + X-QuestDB-Role and rotated past). /// [JsonIgnore] public IReadOnlyList addresses => _addresses.AsReadOnly(); @@ -201,7 +701,7 @@ public AutoFlushType auto_flush public int auto_flush_rows { get => _autoFlushRows; - set => _autoFlushRows = value; + set { _autoFlushRows = value; _autoFlushRowsUserSet = true; } } /// @@ -211,7 +711,7 @@ public int auto_flush_rows public int auto_flush_bytes { get => _autoFlushBytes; - set => _autoFlushBytes = value; + set { _autoFlushBytes = value; _autoFlushBytesUserSet = true; } } /// @@ -227,7 +727,7 @@ public int auto_flush_bytes public TimeSpan auto_flush_interval { get => _autoFlushInterval; - set => _autoFlushInterval = value; + set { _autoFlushInterval = value; _autoFlushIntervalUserSet = true; } } /// @@ -334,27 +834,6 @@ public string? token set => _token = value; } - /// - /// Used in other ILP clients for authentication. - /// - [Obsolete] - [JsonIgnore] - public string? token_x - { - get => _tokenX; - set => _tokenX = value; - } - - /// - /// Used in other ILP clients for authentication. - /// - [Obsolete] - [JsonIgnore] - public string? token_y - { - get => _tokenY; - set => _tokenY = value; - } /// /// Timeout for authentication requests. @@ -388,7 +867,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 +910,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 +920,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 +949,309 @@ public TimeSpan pool_timeout set => _poolTimeout = value; } + /// + /// Hard cap on the number of distinct schemas (column-set permutations) registered on a + /// single WebSocket connection. Defaults to 65535, matching the wire schema-id range. + /// + public int max_schemas_per_connection + { + get => _maxSchemasPerConnection; + set { _maxSchemasPerConnection = value; _maxSchemasPerConnectionUserSet = true; } + } + + /// + /// 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; } + } + + /// + /// If true, the WebSocket sender enables Gorilla delta-of-delta compression for + /// timestamp columns. Falls back to uncompressed per column when DoDs overflow int32. + /// Off by default. + /// + public bool gorilla + { + get => _gorilla; + set { _gorilla = value; _gorillaUserSet = 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; } + } + + /// + /// 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 5 s. + /// + 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; } + } + + /// + /// 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; + } + } + /// /// 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 +1261,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,6 +1283,79 @@ 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) + { + 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"); + } + + host = addr.Substring(0, firstColon); + if (string.IsNullOrWhiteSpace(host)) + { + throw new IngressError(ErrorCode.ConfigError, $"malformed address `{addr}`: empty host"); + } + var portStr = addr.Substring(firstColon + 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. /// @@ -522,12 +1367,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,52 +1402,91 @@ 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) { 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 +1495,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) + { + 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") { - var addrValue = kvp[1].Trim(); - if (!string.IsNullOrEmpty(addrValue)) + 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,14 +1530,12 @@ private void ReadConfigStringIntoBuilder(string confStr) ConnectionString = paramString, }; - VerifyCorrectKeysInConfigString(); - - _connectionStringBuilder.Add("protocol", splits[0]); + _connectionStringBuilder.Add("protocol", protocolPart); } private string? ReadOptionFromBuilder(string name) { - _connectionStringBuilder.TryGetValue(name, out var value); + _connectionStringBuilder!.TryGetValue(name, out var value); return (string?)value; } @@ -640,24 +1553,75 @@ 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. Round-trips through + /// . Secret fields (password, token, + /// tls_roots_password) are omitted entirely; logging this output never leaks secrets, + /// and re-parsing is loss-y for those fields by design. + /// 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; + } + // 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 (SecretPropertyNames.Contains(prop.Name)) + { + continue; + } + if (prop.IsDefined(typeof(JsonIgnoreAttribute), false)) { continue; @@ -673,44 +1637,87 @@ 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 = (IsWebSocket() && prop.Name == nameof(auth_timeout)) + ? "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 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); } } - return $"{protocol.ToString()}::{builder.ConnectionString};"; + var connectionString = builder.ConnectionString; + if (_addresses.Count > 0) + { + var addrValue = string.Join(",", _addresses); + connectionString = $"addr={addrValue};{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 bbe90d1..532b177 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 + + + + + + +