diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..05069ae --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Default: every text file stored and checked out as LF. +# Matches dotnet/runtime, dotnet/sdk convention. +* text=auto eol=lf + +# Windows scripts that genuinely break under LF (cmd.exe parser). +*.cmd text eol=crlf +*.bat text eol=crlf + +# Common binary extensions — never normalize, never diff as text. +*.dll binary +*.exe binary +*.pdb binary +*.snk binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.nupkg binary +*.snupkg binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4297a68..2e7e9e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,16 @@ name: CI on: push: - branches: [main] pull_request: - branches: [main] + +# Cancel superseded runs on the same ref to save runner time when +# commits land back-to-back on a branch / PR. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build-test: + unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -25,15 +29,44 @@ jobs: run: dotnet build --no-restore -c Release - name: Unit tests - run: dotnet test tests/Geode.Client.Tests/Geode.Client.Tests.csproj --no-build -c Release --logger trx --collect:"XPlat Code Coverage" + run: dotnet test tests/Geode.Client.Tests/Geode.Client.Tests.csproj --no-build -c Release --logger trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-results + path: '**/*.trx' + + integration-tests: + # PR-only gate. Integration tests boot a real Geode container via + # Testcontainers and are too slow to run on every push. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -c Release - name: Integration tests - # Testcontainers boots a real Geode container; runs on Linux runner with Docker. + # GitHub-hosted Ubuntu runner ships with native Docker; Testcontainers + # works out of the box. RYUK_DISABLED is only needed for Podman on + # local dev machines — do NOT set it here. run: dotnet test tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj --no-build -c Release --logger trx - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: integration-test-results path: '**/*.trx' diff --git a/.gitignore b/.gitignore index 9bfe57b..92980fb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ Thumbs.db ## Claude Code (per-machine settings, transcripts, etc.) .claude/ + +## Per-user Visual Studio solution files (mirrors geode-dotnet.sln plus personal solution items) +*.local.slnx + +## Visual Studio extension cache (per-user, e.g. CodeRush / similar) +.cr/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9806f18..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,273 +0,0 @@ -# Geode .NET Client — Project Context - -> 這份檔案是 Claude Code 的長期專案記憶。每次 session 啟動時讀過一次, -> 確認當前 phase 後再開始工作。 - ---- - -## 一句話目標 - -寫一個**純 managed、零外部相依、跨平台**的 Apache Geode client, -target **.NET 10 (LTS)**,發到 NuGet。 - -Repository 上游參考: -(C++/CLI 的 `clicache/` **不**移植;它的限制太多,且只能 Windows。) - ---- - -## 路線決策(已定,不要再翻案) - -### 為什麼不選其他路線 - -- **A 路線(C++/CLI 移植到 .NET 10)**:放棄。MS 官方說 C++/CLI on .NET Core - 只為相容性而支援、不會投資、僅 Windows、不能 AOT、不能 SDK-style project。 -- **B1 路線(保留 native cppcache + P/Invoke wrapper)**:放棄。要為每個 RID - 維護 native binary,喪失 .NET 純 managed 的好處;C ABI shim 也是工作量。 -- **B2 路線(純 managed,自己講 wire protocol)**:✅ **採用**。 - -### B2 的代價與對策 - -Geode wire protocol **沒有官方規格文件**(Apache 自己 wiki 承認), -只能從 `cppcache/src/` 與 Java `geode-core` 兩邊反推。 - -對策:**功能範圍縮到 MVP**。只做 put/get/query/CRUD, -CQ / function / transaction / HA / delta 全部不在 MVP 範圍。 - ---- - -## 相依策略 - -**零外部 NuGet 相依**(除了 test 工具)。 - -| cppcache 用的 | 我們的對策 | -| --- | --- | -| Boost.Asio | `System.Net.Sockets` + `System.IO.Pipelines` + `Channels` | -| OpenSSL | `System.Net.Security.SslStream` | -| Xerces-C (cache.xml) | **直接砍掉**,改用 `Microsoft.Extensions.Configuration` | -| SQLite (overflow) | MVP 不做 | -| Google Test / Benchmark | xUnit v3 / BenchmarkDotNet | - -設定走 .NET 慣例:`appsettings.json` + `IOptions`。 -**不支援 cache.xml、不支援 .ini**。 - ---- - -## API 表面(DI-first) - -使用者只看到一個 extension method 跟兩個介面: - -```csharp -// 註冊 -builder.Services.AddGeodeClient(builder.Configuration.GetSection("Geode")); - -// 使用 -public class OrderService(IGeodeCache cache) -{ - private readonly IRegion _orders = cache.GetRegion("orders"); - public Task GetAsync(string id) => _orders.GetAsync(id); -} -``` - -主要介面: - -```csharp -public interface IGeodeCache -{ - IRegion GetRegion(string name); - IQueryService QueryService { get; } -} - -public interface IRegion -{ - string Name { get; } - Task PutAsync(TKey key, TValue value, CancellationToken ct = default); - Task GetAsync(TKey key, CancellationToken ct = default); - Task RemoveAsync(TKey key, CancellationToken ct = default); - Task> GetAllAsync(IEnumerable keys, CancellationToken ct = default); - Task PutAllAsync(IDictionary entries, CancellationToken ct = default); -} - -public interface IQueryService { IQuery NewQuery(string oql); } -public interface IQuery { Task> ExecuteAsync(CancellationToken ct = default); } -``` - -設定 schema: - -```json -{ - "Geode": { - "Locators": ["host1:10334", "host2:10334"], - "Servers": ["host1:40404"], - "Pool": { "MinConnections": 1, "MaxConnections": 10, "ReadTimeout": "00:00:10" }, - "Tls": { "Enabled": false }, - "Auth": { "Username": null, "Password": null } - } -} -``` - -**重要**:MVP 階段不需要支援 cache.xml / Region 建立。Region 由 DBA 用 gfsh -建好(`gfsh create region --name=test --type=REPLICATE`),client 只是 proxy。 - ---- - -## Protocol 三層架構 - -``` -┌──────────────────────────────────────────────┐ -│ Operation 層: PutAsync, GetAsync, ... │ C# public API -├──────────────────────────────────────────────┤ -│ Message 層: TcrMessage 編解碼 │ MessageType + Parts -├──────────────────────────────────────────────┤ -│ Frame 層: header + part bytes │ 純 byte I/O -├──────────────────────────────────────────────┤ -│ Transport: TcpClient + SslStream │ BCL -└──────────────────────────────────────────────┘ -``` - -### Frame 結構(all big-endian / network byte order) - -``` -+------------------+------------------+------------------+------------------+ -| MessageType i32 | MessageLength i32| NumParts i32 | TransactionId i32| -+------------------+------------------+------------------+------------------+ -| EarlyAck u8 | | -+------------------+--------------------------------------------------------+ -| Part 1, Part 2, ... NumParts parts | -+----------------------------------------------------------------------------+ - -Part: -+------------------+----------+--------+-------------+ -| PartLength i32 | IsObject | Type | Payload | -| | u8 | u8 | (PartLen B) | -+------------------+----------+--------+-------------+ -``` - -### Handshake(最容易踩雷的一段) - -**不**走標準 frame 格式,是 ad-hoc bytes。請逐 byte 對著 -`cppcache/src/TcrConnection.cpp::sendHandshakeForServer` 翻譯,**不要靠記憶**。 - -``` -client → server: - ConnectionType u8 (100 = client-to-server) - ReplyOk u8 (59) - ProtocolVersion (major.minor.patch + ordinal) - ClientProxyMembershipID (serialised: host/PID/UUID/durable id) - Credentials (optional Properties) - -server → client: - AcceptanceCode u8 (38 = OK) - ServerQueueStatus u8 - QueueSize i32 - ServerMember (membership ID) - DeltaEnabled u8 -``` - -### MVP MessageType 子集 - -從 `cppcache/src/TcrMessage.hpp` 抓出: - -| 值 | 名稱 | 用途 | -|---|---|---| -| 0 | Request | GET | -| 1 | Response | GET reply | -| 2 | Exception | server 錯誤 | -| 5 | Ping | 健康檢查 | -| 6 | Reply | ack | -| 7 | Put | PUT | -| 9 | Destroy | REMOVE 單 key | -| 18 | CloseConnection | bye | -| 34 | Query | OQL | -| 38 | ContainsKey | | -| 56 | PutAll | | -| 99 | ServerToClientPing | server 主動 ping | -| 100 | GetAll70 | | - -### 序列化(MVP) - -只做以下 DSFID(對應 `cppcache/include/geode/internal/DSCode.hpp`): - -- String (DSFID 87) -- Integer / Long -- Boolean / Double -- Date -- byte[] / null - -**PDX 不在 MVP**(Phase 11 才做)。 - ---- - -## 開發順序(12 phases) - -每個 phase 都是「walking skeleton」,end-to-end 跑通才往下。 - -| Phase | 內容 | 工時估 | 完成條件 | -|---|---|---|---| -| 0 | 環境與骨架(這份 zip)| 0.5w | solution build / Docker server up | -| 1 | Frame codec 純編解碼 | 0.5w | 對 byte fixture 來回測試通過 | -| 2 | **Slice 1: Ping 通**(含 handshake)| 1–2w | server 回 Reply(6) | -| 3 | **Slice 2: Put/Get 通** | 1w | put `byte[]` 再 get 回來相等 | -| 4 | 型別擴充(Int/Long/Bool/Date)| 1w | 各型別整合測試 | -| 5 | API + DI 包裝 | 0.5w | `IGeodeCache` 可注入、可 demo | -| — | **第一版 NuGet `0.1.0-alpha`** | | 可發佈 | -| 6 | Connection Pool | 1w | 高併發 + server restart 自動恢復 | -| 7 | Locator discovery | 0.5w | 只給 locator 也能連 | -| 8 | TLS (`SslStream`) | 0.5w | 對 SSL server 能連 | -| 9 | Authentication | 0.5w | username/password | -| 10 | Query / OQL | 1w | `SELECT * FROM /r WHERE x>10` | -| 11 | PDX 序列化 | 2w | 跟 Java client 互通 | -| 12+ | CQ / Function / TX / HA / Delta | 之後 | 進階功能,視需求 | - ---- - -## 重要原則 - -1. **先讀 cppcache,不要憑空設計 protocol**。`TcrMessage.cpp`、`TcrConnection.cpp`、 - `HandShake.cpp`、`ThinClientPoolDM.cpp` 是規格。 -2. **Walking skeleton**:每個 phase 跑通端到端,不要做完整層才往上。 -3. **Frame codec 一定寫單元測試**,用 Wireshark 抓的 byte fixture 對照。 -4. **不要過度抽象**。底層程式碼先寫具體 class,到 Phase 5 要做 DI 才 extract interface。 -5. **Big-endian**(`BinaryPrimitives.WriteInt32BigEndian`)。Geode 是 Java,全網路位元序。 - ---- - -## 工具鏈 - -- **.NET 10 SDK** (LTS, 2025-11 GA) -- **xUnit v3** + FluentAssertions(assertion 風格) -- **Testcontainers**:整合測試自動起 `apachegeode/geode` container -- **GitHub Actions**:CI on PR/push、release on tag -- **NuGet**:`MinVer` 從 git tag 取版本號 -- **Source Link** + `.snupkg`:使用者能 step into 原始碼 -- **Apache-2.0** 授權(與上游一致) - ---- - -## 內外網同步(Tomi 環境特化) - -開發者 Tomi 使用 dual-network 工作流: - -- 外網(internet):主開發、GitHub、CI、發 NuGet -- 內網(air-gapped):CI/CD 測試,內網 GitLab/GitHub -- 同步方式:USB bare repo -- 分支:`main`(feature)、`ci/offline`(CI/CD config,**只活在內網**) -- 規則:只有 reviewed/approved 的 `main` 才透過 USB 帶進內網 - -**不要**在 main 直接 commit。所有變更走 PR + review。 - ---- - -## 下一步 - -Phase 0 已經由本骨架提供(solution、csproj、workflow、docker-compose)。 -**從 Phase 1 開始**:實作 Frame codec。 - -啟動指令範例: - -``` -讀 CLAUDE.md。我們從 Phase 1 開始: -1) 在 src/Geode.Client/Protocol/ 建 BigEndianBinaryReader/Writer -2) 建 TcrPart, TcrMessage record -3) 在 tests/Geode.Client.Tests/Protocol/ 寫 frame round-trip 單元測試 -照 walking skeleton 原則做,先把最小路徑跑通。 -``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d9a541b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,320 @@ +# Contributing to Geode .NET Client + +This document defines **how** we develop and merge changes. The roadmap and +architectural decisions live in [`CLAUDE.md`](./CLAUDE.md); this file covers +process only. + +> Project language is **English** for code, commits, branches, PRs, and +> issues. + +--- + +## 1. Branching model + +We use a simplified GitFlow with three roles: + +``` + ┌──────────────┐ + │ main │ release-ready, tagged, NuGet source + │ (protected) │ + └──────▲───────┘ + │ PR (release cut) + ┌──────┴───────┐ + │ develop │ integration branch, day-to-day target + └──────▲───────┘ + │ PR (squash) + ┌────────┴────────┐ + │ feat/phase-N │ one feature branch per change + │ fix/... │ + │ docs/... │ + │ chore/... │ + └─────────────────┘ +``` + +### 1.1 `main` + +- **Protected.** No direct pushes. PR-only. +- Gets content **only** by merging `develop` (release cuts). +- Tags (`v*.*.*`, `v*.*.*-*`) are pushed from `main` and trigger + `release.yml`. +- Linear history (squash / rebase only — merge commits are blocked by + branch protection). +- Crosses the USB boundary into the intranet (see §6). + +### 1.2 `develop` + +- Day-to-day integration branch. **All feature work targets this.** +- Less ceremonial than `main` but the same commit-quality bar. +- Gets reset / merged into `main` whenever a release is cut. + +### 1.3 Short-lived branches + +``` +feat/phase-N- e.g. feat/phase-1-frame-codec +fix/ bug fixes +docs/ documentation only +chore/ build, deps, tooling +test/ tests only +refactor/ behaviour-preserving refactors +ci/ GitHub Actions / pipeline changes +``` + +`ci/offline` is **reserved** for the air-gapped intranet pipeline and must +never be pushed to `origin`. See §6. + +Delete the branch after the PR is merged. + +--- + +## 2. Development principles + +### 2.1 Walking skeleton, phase by phase + +The project ships in 12 phases (see `CLAUDE.md`). Each phase is a vertical +slice — the code must run end-to-end against a real Geode server (or a +deterministic fixture) before the next phase begins. **Do not** build a +complete layer in isolation and stack the next layer on top. + +### 2.2 One feature branch per change + +A PR should be a single logical change. Mixing a refactor with a feature, or +a dependency bump with a bug fix, makes review and `git bisect` worse. +Split. + +### 2.3 Read the C++ source before designing the protocol + +Geode's wire protocol has no normative spec. The authoritative sources are: + +- `apache/geode-native` → `cppcache/src/TcrMessage.cpp`, + `TcrConnection.cpp`, `HandShake.cpp`, `ThinClientPoolDM.cpp` +- `apache/geode` (Java) → `geode-core` for server-side semantics + +Cite the exact file and function in PR descriptions when you implement +protocol-level code. Do not paraphrase from memory. + +### 2.4 Endianness and serialisation + +All wire bytes are big-endian (network byte order). Use +`System.Buffers.Binary.BinaryPrimitives.*BigEndian`. Custom byte-shuffling +is grounds for a review block. + +### 2.5 Tests required for protocol code + +Frame codec, message types, and serialisation paths **must** have unit tests +backed by byte-level fixtures (golden bytes from `cppcache` or Wireshark +captures). Behaviour-only assertions are not enough at the wire layer. + +### 2.6 Zero external runtime dependencies + +The published NuGet package depends only on `Microsoft.Extensions.*` +abstractions. Adding any other runtime `PackageReference` requires a +PR-level discussion and a written justification (link the relevant section +of `CLAUDE.md` if applicable). + +### 2.7 Treat warnings as errors (when CI is on) + +`Directory.Build.props` sets `TreatWarningsAsErrors=true`. Do not silence +warnings with `#pragma` unless you also add a code comment explaining why. +Per-symbol `[SuppressMessage]` with a `Justification` is acceptable. + +> See §5 for the current CI status — analyzer strictness is real even when +> CI is disabled, because every developer's local build enforces it. + +--- + +## 3. Commit conventions + +We follow a reduced [Conventional Commits](https://www.conventionalcommits.org/) +subset. + +### 3.1 Allowed types + +| Type | Use for | +| ----------- | ----------------------------------------------- | +| `feat:` | New user-visible functionality | +| `fix:` | Bug fix | +| `chore:` | Build, deps, tooling, gitignore, repo plumbing | +| `docs:` | Documentation only | +| `test:` | Tests only (no production code change) | +| `refactor:` | Behaviour-preserving refactor | +| `ci:` | GitHub Actions or pipeline changes | + +### 3.2 Format + +``` +: + + + + +``` + +Examples: + +``` +feat: add big-endian binary writer for frame codec +fix: handle short read in message header parser +chore: bump xunit.v3 to 1.0.1 +ci: re-enable build-test workflow +``` + +### 3.3 Linking issues + +Reference the issue in the commit body **or** the PR description, not the +summary line. Use `Refs: #N` for context, `Closes #N` to auto-close on +merge. + +--- + +## 4. Pull request and merge rules + +### 4.1 Targets + +| Source | Target | Purpose | +| --------------------- | --------- | ------------------------------------ | +| `feat/*` `fix/*` etc. | `develop` | normal day-to-day work | +| `develop` | `main` | release cut (see §7) | + +Never open a PR from `feat/*` directly to `main` unless it is a hotfix that +must skip `develop` (and even then, back-port to `develop` immediately). + +### 4.2 PR template is mandatory + +Fill in every section of `.github/pull_request_template.md`: + +- **Phase** — which phase from the roadmap (or `N/A` for chores). +- **Changes** — bullet list of what changed. +- **Tests** — what you added / why existing coverage is enough. +- **Notes for reviewer** — open questions, things to look at first. + +### 4.3 Self-review before merging + +Open the PR's **Files changed** tab and read it as if you were a reviewer. +Most stylistic / leftover-debug-print issues catch themselves this way. + +### 4.4 Approval policy (solo-dev mode) + +While the project has only one regular contributor, the author may +self-merge once the build is green and the self-review pass is done. +Branch protection on `main` requires the PR to exist; it does not require +an approver count > 0. + +When a second regular contributor joins, raise +`required_approving_review_count` to `1` via `gh api PUT +repos/.../branches/main/protection`. + +### 4.5 Merge mode + +| Target | Allowed merge mode | Why | +| --------- | ------------------ | -------------------------------------- | +| `develop` | Squash | one PR = one commit on `develop` | +| `main` | Squash *or* Rebase | linear history is enforced by branch | +| | | protection (`required_linear_history`) | + +The squashed commit message must follow §3. + +### 4.6 Build must be green (when CI is on) + +When CI is enabled, the `build-test` job must be green before merge. Do +not bypass red checks. If CI is flaky, fix the flake — do not re-run +until green. + +### 4.7 Delete the branch after merge + +Both locally (`git branch -d feat/...`) and on origin. GitHub can do this +automatically — leave the **"Automatically delete head branches"** repo +setting on. + +--- + +## 5. CI status + +> **CI is currently DISABLED during the MVP phases.** + +`.github/workflows/ci.yml` is fully commented out. The `release.yml` +workflow is left intact but only triggers on `v*.*.*` tag pushes, so it +cannot fire accidentally during normal work. + +### 5.1 Why disabled + +`Directory.Build.props` sets `AnalysisLevel=latest-recommended` plus +`TreatWarningsAsErrors=true`. The Phase 0 skeleton itself violates several +opinionated analyzer rules (CA1848, CA1711, ...). A push / red CI / fix / +push loop has no useful signal at this stage and only wastes CI minutes. + +### 5.2 When to re-enable + +At the **latest**, before Phase 5 / first NuGet preview release. Earlier +is fine if the analyzer strictness has been settled (either relax to +`latest-default`, or pre-fix every violation in the skeleton). + +### 5.3 How to re-enable + +1. Uncomment the body of `.github/workflows/ci.yml`. +2. Push the change as `ci: re-enable CI workflow`. +3. Add `build-test` to `main`'s required status checks: + ```bash + gh api -X PATCH repos/TomiCheng/GeodeSharp/branches/main/protection/required_status_checks \ + -f 'contexts[]=build-test' + ``` + (Or via the GitHub UI: **Settings → Branches → main → Edit → Status + checks**.) + +--- + +## 6. Dual-network workflow + +The maintainer (`Tomi`) develops on two networks: + +- **Internet side** — `origin` on GitHub, public CI, NuGet publish. +- **Intranet side** — air-gapped enterprise GitLab / GitHub, internal CI. + +Sync is one-way: `main` on the internet → USB bare repo → intranet. + +Rules: + +1. Only **reviewed and merged** commits on `main` cross the USB boundary. +2. `develop`, feature branches, and PR branches **do not** cross. The + intranet has no business seeing WIP. +3. The branch `ci/offline` carries intranet-only CI/CD configuration. It + **must never** be pushed to `origin` (the public GitHub remote). +4. Intranet-side commits stay intranet-side. They are not back-ported to + `origin` unless explicitly cleaned and re-authored as a public PR. + +--- + +## 7. Releasing + +Releases are tag-driven. The procedure: + +1. Open a release PR: `develop` → `main`. Title: `release: vX.Y.Z[-pre]`. +2. Self-review the diff (everything that has accumulated on `develop` + since the last release tag). +3. Squash-merge into `main`. The squash commit message should be + `release: vX.Y.Z[-pre]` plus a brief change log in the body. +4. Tag `main` locally and push: + ```bash + git checkout main && git pull + git tag v0.1.0-alpha + git push origin v0.1.0-alpha + ``` +5. `release.yml` packs, pushes to NuGet, and creates a GitHub Release with + auto-generated notes. +6. (Optional) Fast-forward `develop` to `main` so they do not diverge: + ```bash + git checkout develop && git merge --ff-only main && git push + ``` + +Versioning follows [SemVer 2.0](https://semver.org/). Pre-1.0 the public +API may change between minor versions; we mark unstable phases with +`-alpha` / `-beta` suffixes (MinVer derives the version from the tag). + +--- + +## 8. Where to ask + +- Architecture / roadmap questions → read `CLAUDE.md` first, then open a + GitHub Discussion or issue tagged `question`. +- Bugs → open an issue with a minimal repro. +- Protocol-level design questions → cite the `cppcache` or `geode-core` + source you read; that is the conversation starter. diff --git a/Directory.Build.props b/Directory.Build.props index a475f99..0a91215 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,8 +4,12 @@ latest enable enable - true - latest-recommended + false + + latest-default true diff --git a/Directory.Packages.props b/Directory.Packages.props index f439383..199afd9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,6 @@ true true - @@ -13,23 +12,19 @@ - - - - - - - + + + + + - - diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..63887df --- /dev/null +++ b/NuGet.config @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/PORTING.md b/PORTING.md new file mode 100644 index 0000000..6d73c4a --- /dev/null +++ b/PORTING.md @@ -0,0 +1,394 @@ +# C++ ↔ C# class mapping + +> Mapping between cppcache classes and the C# port. Each row records +> the porting bucket (see [CLAUDE.md](.claude/CLAUDE.md) "Three-bucket porting +> rule"), the C# visibility (public API surface vs internal +> implementation), and the implementation status. +> +> **This is a living document.** Add a row whenever you encounter a +> new cppcache class while working on a feature. Update the status +> column when the implementation moves forward. + +## Status legend + +| Symbol | Meaning | +| --- | --- | +| ✅ | Implemented (skeleton + body) | +| 🔨 | Skeleton only (interface declared, body throws / empty) | +| ⏳ | Planned for a future phase, not yet stubbed | +| 🚫 | Bucket 1 — BCL covers it, will not be ported | +| ❌ | Out of scope (cut from MVP / not implemented) | + +## Visibility legend + +| Symbol | Meaning | +| --- | --- | +| 🌐 | **Public** — part of `Geode.Client` public API surface (corresponds to cppcache `clicache/`) | +| 🔒 | **Internal** — implementation detail (`internal` modifier; corresponds to cppcache `cppcache/src/`) | +| — | N/A (bucket 1 / 3 wrapper / not a class) | + +--- + +## 1. Public API surface 🌐 (corresponds to cppcache `clicache/`) + +These are the types a consumer of the NuGet package can `using`. Names +follow the cppcache `clicache/` C++/CLI managed wrapper where one +exists; they are translated, not ported. + +| cppcache (clicache) | C# | Status | Phase | Notes | +| --- | --- | --- | --- | --- | +| `RegionService` (top abstract) | `Geode.Client.IRegionService` | 🔨 | 0 | Lifecycle surface only today (`IsClosed` / `CloseAsync` / `IAsyncDisposable`); region/query/PDX methods land in 1.2 / 1.4 / 2 | +| `GeodeCache` (mid abstract) | `Geode.Client.IGeodeCache : IRegionService` | 🔨 | 0 | Adds `Name` + `EnsureInitializedAsync`; PDX config accessors land in Phase 2 | +| `Cache` (concrete) | _no separate public interface_; `Geode.Client.Services.Cache` is the impl (see §2) | 🔨 | 1.x | cppcache `Cache` adds `createRegionFactory` / `getCacheTransactionManager` / `getPoolManager` / `createAuthenticatedView` etc. — most live on `IGeodeCache` directly when their phase ships; revisit splitting into a separate "ICache" interface only if multi-user (Phase 3) requires it | +| `Apache::Geode::Client::IRegion` | `Geode.Client.IRegion` | 🔨 | 1.2 | Empty marker; methods land in 1.2 | +| `Apache::Geode::Client::IQueryService` | `Geode.Client.IQueryService` | 🔨 | 1.4 | Empty marker; `NewQuery` in 1.4 | +| `Apache::Geode::Client::IQuery` | `Geode.Client.IQuery` | 🔨 | 1.4 | Empty marker; `ExecuteAsync` in 1.4 | +| `PoolFactory` | _undecided_ | ⏳ | 1.5 | Decided: `PoolManager.createFactory()` is **not** ported — pools are not built off the manager. Undecided: whether a separate `PoolFactory` type is needed at all. Pool construction may go through DI / `AddGeodeClient`, but final shape pending. | +| `Apache::Geode::Client::CacheFactory` | `Geode.Client.IGeodeCacheFactory` | ✅ | 0 | Same role (gateway to `Cache` instances), not the same mechanics — see *CacheFactory ↔ IGeodeCacheFactory* note below | +| `Apache::Geode::Client::GeodeException` | `Geode.Client.GeodeException` | ✅ | 0 | | +| `cache.xml` configuration | `Geode.Client.Options.GeodeClientOptions` + sub-options | ✅ | 0 | mirror-then-prune; see `Options/` folder | +| _additional clicache types to be enumerated as we encounter them_ | | ⏳ | | TODO: full sweep of `D:\github\geode-native\clicache\src\` | + +### Note: `CacheFactory` ↔ `IGeodeCacheFactory` + +Same role (the public entry point that produces / hands out `Cache` +instances) but the mechanics differ — this is a "translate + +modernise" mapping (per CLAUDE.md), not a literal port. + +| Aspect | cppcache `CacheFactory` | C# `IGeodeCacheFactory` | +| --- | --- | --- | +| **Pattern** | Fluent builder | DI-resolved factory | +| **Construction** | `CacheFactory()` / `CacheFactory(props)` + chained `set(k, v)` | `services.AddGeodeClient(...)` at composition root | +| **Resolution** | `factory.create()` returns a fresh `Cache` | `factory.Get(name)` looks up the cache registered under that name | +| **Lifetime** | Caller owns the returned `Cache` | DI container owns; resolved instances are singletons-per-name | +| **Number of caches** | One per `create()` call; no built-in registry | Multiple named caches in one process; registry keyed by name | +| **Configuration source** | `Properties` bag (typically loaded from `.ini`) | `IConfiguration` / `IOptions` | + +cppcache supports multiple `Cache` instances ([CacheFactory.cpp:65](https://github.com/apache/geode-native/blob/develop/cppcache/src/CacheFactory.cpp) constructs a fresh one per call; nothing is `static`). It just doesn't ship a registry — callers track instances themselves. The C# port adds the registry layer because DI named-options is the .NET-idiomatic way to expose multiple cluster connections from one app. + +## 2. Internal implementation 🔒 (corresponds to cppcache `cppcache/src/`) + +These are `internal sealed` (or `internal abstract`) classes. Names +mirror cppcache file-for-file unless explicitly noted, per the +"Three-bucket porting rule" bucket 2. + +### Cache & region core + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `Cache` (façade) + `CacheImpl` (Pimpl body) | `Geode.Client.Services.Cache` (single class, implements public `IGeodeCache`) | 2 | 🔨 | 1.1 | cppcache's Pimpl split (`Cache` → `m_cacheImpl`) is collapsed — .NET doesn't need the binary-compatibility shim. `InitializeCoreAsync` is the next entry point | +| (DI factory layer) | `Geode.Client.Services.GeodeCacheFactory` | — | ✅ | 0 | New, no cppcache analogue | +| `ThinClientRegion` | `Geode.Client.Services.ThinClientRegion` (non-generic) | 2 | ✅ | 1.2–1.3.c | All bulk + single-key ops end-to-end (Put / Get / Remove / ContainsKey / Clear / Invalidate / RemoveAll / PutAll / GetAll). Stays non-generic to mirror cppcache native; typed surface goes through `RegionView` wrapper. Sub-region path / caching-enabled local map deferred (Phase 2+) | +| `LocalRegion` | `Geode.Client.Internal.LocalRegion` (abstract) | 2 | 🔨 | 1.2 | Empty placeholder layer; just holds Name / FullPath / Parent. Local-cache machinery (`m_entries` / listener / writer / loader) deferred to Phase 2+ when `caching-enabled` is honoured | +| `RegionInternal` | `Geode.Client.Internal.RegionInternal` (abstract) | 2 | 🔨 | 1.2 | Empty placeholder layer; holds `Attributes` and forwards `PoolName`. Internal-only API surface (EventId-aware ops, version stamps, tombstones) deferred to Phase 2+ | +| `Region` (base) | `Geode.Client.IRegion` (non-generic) + `Geode.Client.IRegion` (typed overlay) | 2 | 🔨 | 1.2–1.4 | Non-generic interface holds the real op surface (`object` keys / values); typed interface is overload-only sugar. **Covered:** Put / Get / Remove / ContainsKey (=`containsKeyOnServer`) / Clear / Invalidate / PutAll / GetAll / RemoveAll / ExistsValue / SelectValue. **Routed elsewhere:** `query(predicate)` → `IQueryService.NewQuery`; `getStatistics` → `System.Diagnostics.Metrics.Meter`. **Deferred (Phase 1.5):** full `IPool` accessor (today only `PoolName`). **Deferred (Phase 2+):** `create` / `destroy` / `destroyRegion` / `invalidateRegion` / `removeEx` (distinct-from-`put`/`remove` exception semantics), `getEntry` / `keys` / `values` / `entries` / `size` / `isDestroyed`, `getAttributes` / `getAttributesMutator` (needs `RegionAttributes` port). **Cut (per CLAUDE.md «Not implemented»):** sub-regions (`getParentRegion` / `getSubregion` / `createSubregion` / `subregions` / `localDestroyRegion`), local-* mirrors (`localPut` / `localCreate` / `localInvalidate` / `localDestroy` / `localRemove` / `localRemoveEx` / `localClear` / `localInvalidateRegion`), interest-list / CQ subscription (`getInterestList[Regex]` / `register[All]Keys` / `unregister[All]Keys` / `register[Unregister]Regex`). | +| (no cppcache analogue) | `Geode.Client.Services.RegionView` | — | ✅ | 1.2 | Compile-time-only typed wrapper; new instance per `Cache.GetRegion(name)` call. cppcache splits typed/untyped across native + clicache layers; C# folds both into one | + +### Distribution managers (Phase 1.5) + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `ThinClientBaseDM` | `Geode.Client.Internal.ThinClientBaseDM` | 2 | 🔨 | 1.5 | Abstract base shell: lifecycle, chunk Channel, security hooks (default empty), pure-abstract `SendSyncRequestAsync` / `SendRequestToEndpointAsync` | +| `ThinClientDistributionManager` | `Geode.Client.Internal.ThinClientDistributionManager` | 2 | ⏳ | 1.5 | Simple single-endpoint; used by locator path | +| `ThinClientPoolDM` | `Geode.Client.Internal.ThinClientPoolDM` | 2 | 🔨 | 1.5 | Pool variant shell: inherits `ThinClientBaseDM`, implements `IPool`. Field placeholders for endpoint registry, connection queue, three background workers, locator helper, redundancy / sticky / metadata managers. Method prototypes throw NotImplementedException | +| `ThinClientStickyManager` | `Geode.Client.Internal.Dm.ThinClientStickyManager` | 2 | ⏳ | 6 | `AsyncLocal` instead of TSS | + +### Connection / endpoint + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `TcrConnection` | `Geode.Client.Protocol.TcrConnection` | 2 | 🔨 | 1.1 | Frame I/O works; handshake bytes done; `InitializeCoreAsync` not wired yet | +| `Pool` (cppcache `include/geode/Pool.hpp`, public abstract) | `Geode.Client.Internal.IPool` | 2 | 🔨 | 1.5 | Held internal — no MVP consumer use case; lift to public later if monitoring / advanced lifecycle hooks need it. Sole implementor will be `ThinClientPoolDM` | +| `PoolManager` + `PoolManagerImpl` (cppcache abstract + Pimpl body) | `Geode.Client.Internal.PoolManager` | 2 | 🔨 | 1.5 | Pimpl collapsed; no separate `IPoolManager` interface — only one implementor, internal use only | +| `TcrConnectionManager` | `Geode.Client.Internal.TcrConnectionManager` | 2 | 🔨 | 1.5 | Empty shell with TODO + cppcache member notes; will own 3 background tasks + ping `PeriodicTimer` | +| `TcrEndpoint` | `Geode.Client.Internal.TcrEndpoint` | 2 | 🔨 | 1.5 | Per-server state shell: per-endpoint conn pool, health flags, auth token, subscription receiver placeholders. Method prototypes throw NotImplementedException. Unsealed — base for the `TcrPoolEndPoint` split | +| `TcrPoolEndPoint` | `Geode.Client.Internal.TcrPoolEndPoint` | 2 | 🔨 | 1.5 | Empty skeleton subclass; cppcache parity (`TcrEndpoint` → `TcrPoolEndPoint`). Pool-only state (m_dm ref, getPoolHADM accessor, pool-mode registerDM override, handleNotificationStats for #21) migrates here step by step. Pool callers still construct base `TcrEndpoint` until migration | +| `ConnectionQueue` | (wrapper over `Channel`) | 3 | ⏳ | 1.5 | thin wrapper that adds timed-get-or-create | +| `ThinClientLocatorHelper` | `Geode.Client.Internal.ThinClientLocatorHelper` | 2 | ⏳ | 1.5 | locator wire protocol | + +### Query (Phase 1.4) + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `RemoteQueryService` | `Geode.Client.Internal.RemoteQueryService` | 2 | ✅ | 1.4 | Pool-scoped `IQueryService` impl; `NewQuery` Phase 1.4 surface. CQ entry points + non-pool `init()` reappear Phase 2 | +| `RemoteQuery` | `Geode.Client.Internal.RemoteQuery` | 2 | ✅ | 1.4 | `IQuery` impl; `ExecuteCoreAsync` B1-B11 incl. `Query(34)` / `QueryWithParameters(80)` wire dispatch | +| `ProxyRemoteQueryService` | `Geode.Client.Internal.ProxyRemoteQueryService` | 2 | 🔨 | 3 | Empty shell — Phase 3 multi-user wiring point; `NewQuery` NIE, no CQ methods until Phase 2 | + +### Wire protocol primitives + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `TcrMessage` | `Geode.Client.Protocol.TcrMessage` | 2 | ✅ | 1.1 | unit tested | +| `TcrMessageReply` | merged into `TcrMessage` | 2 | ✅ | 1.1 | C# uses one class for both directions | +| (request builders, partial files in cppcache) | `Geode.Client.Protocol.TcrMessageBuilder` (+ `.Get` / `.Put` / `.Ping` / `.ContainsKey` / `.Destroy` / `.ClearRegion` / `.Invalidate` / `.RemoveAll` / `.PutAll` / `.GetAll` / `.CloseConnection` partials) | 2 | ✅ | 1.1–1.3.c | unit tested; new partials track sub-phases | +| `TcrPart` | `Geode.Client.Protocol.TcrPart` | 2 | ✅ | 1.1 | unit tested | +| (part builder) | `Geode.Client.Protocol.TcrPartBuilder` | 2 | ✅ | 1.1 | unit tested | +| `MessageType` enum | `Geode.Client.Protocol.MessageType` | 2 | ✅ | 1.1 | full enum with upstream gaps preserved | +| `DSCode` | `Geode.Client.Protocol.DSCode` | 2 | ✅ | 1.1 | | +| `ProtocolVersion` | `Geode.Client.Protocol.ProtocolVersion` | 2 | ✅ | 1.1 | | +| `ClientProxyMembershipID` (builder) | `Geode.Client.Protocol.ClientProxyMembershipIdBuilder` | 2 | ✅ | 1.1 | unit tested | +| `ClientProxyMembershipID` (decoder used by VersionTag) | `Geode.Client.Protocol.ClientProxyMembershipID` | 2 | ✅ | 1.3.b | `ReadEssentialData` decoder; primary ctor takes `SerializationRegistry` | +| big-endian byte I/O macros / helpers | `BigEndianBinaryReader` / `BigEndianBinaryWriter` | 2 | ✅ | 1.1 | unit tested | + +### Chunked reply / version tags (Phase 1.3.b + 1.3.c) + +Bulk ops (`RemoveAll` / `PutAll` / `GetAll70`) ship their reply over multiple wire chunks; these types decode that stream. + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `TcrChunkedResult` | `Geode.Client.Protocol.TcrChunkedResult` (abstract) | 2 | ✅ | 1.3.b | `HandleChunk(payload, isLastChunk)` + `Reset()`; cppcache `finalize` / `binary_semaphore` / `m_ex` / `m_dsmemId` collapsed (Task/await + natural exception propagation) | +| `TcrMessageHelper` | `Geode.Client.Protocol.TcrMessageHelper` | 2 | ✅ | 1.3.b | `ReadChunkPartHeader` classifies a chunk into NullObject / Object / Exception / Bytes | +| `ChunkObjectType` | `Geode.Client.Protocol.TcrMessageHelper.ChunkObjectType` enum | 2 | ✅ | 1.3.b | NullObject / Object / Exception / Bytes | +| `ChunkedRemoveAllResponse` | `Geode.Client.Services.ChunkedRemoveAllResponse` | 2 | ✅ | 1.3.b | only accumulates version tags (Phase 1.3 drops them); 5-step HandleChunk | +| `ChunkedPutAllResponse` | `Geode.Client.Services.ChunkedPutAllResponse` | 2 | ✅ | 1.3.c | structurally identical to RemoveAll; log strings differ | +| `ChunkedGetAllResponse` | `Geode.Client.Services.ChunkedGetAllResponse` | 2 | ✅ | 1.3.c | extra ctor params: caller's `IReadOnlyList keys` (positional reverse-lookup) + `bool addToLocalCache`; `Values` accumulator surfaces as `IReadOnlyDictionary`; no NullObject / Bytes branches (cppcache GetAll is Object-or-Exception only) | +| `CacheableObjectPartList` | `Geode.Client.Protocol.CacheableObjectPartList` | 2 | 🔨 | 1.3.b | base class — fields only; full decoder lives on `VersionedCacheableObjectPartList` | +| `VersionedCacheableObjectPartList` | `Geode.Client.Protocol.VersionedCacheableObjectPartList` | 2 | ✅ | 1.3.b–1.3.c | 7-step `FromData` decoder; 1.3.c added `Initialize(keys, keysOffset, values, exceptions?, resultKeys?, addToLocalCache)` + `ConsumedObjectCount` accessor for GetAll's shared-accumulator pattern; Step 7 (`putLocal` merge) NIE gated on `AddToLocalCache` (Phase 4+) | +| `VersionTag` | `Geode.Client.Protocol.VersionTag` | 2 | ✅ | 1.3.b | 8-step `FromData` + 2-step `ReadMembers`; primary ctor `(IServiceProvider, ILogger, MemberListForVersionStamp)`; Phase 1.3.c: `MemberListForVersionStamp` now DI-resolved (not positional) | +| `DiskVersionTag` | `Geode.Client.Protocol.DiskVersionTag` | 2 | 🔨 | 1.3.b | inherits `VersionTag`; `ReadMembers` override NIE — persistent regions only (Phase 4+) | +| `MemberListForVersionStamp` | `Geode.Client.Protocol.MemberListForVersionStamp` | 2 | ✅ | 1.3.b–1.3.c | Scoped DI registration added 1.3.c (mirrors cppcache `CacheImpl::m_memberListForVersionStamp` instance scope); hashKey dedup deferred Phase 4 | +| `DSFid` enum | `Geode.Client.Protocol.DSFid` | 2 | ✅ | 1.3.b | 25 entries; `VersionedObjectPartList = 7` / `DiskVersionTag = 2131` etc. | + +### DSCode coverage (built-in type-code catalogue) + +Every value the wire's SerializationRegistry dispatch can +encounter, sorted by DSCode number. "Status" = `✅` registered today, +`⏳` planned/deferred, `❌` won't port (wire-internal or +rarely-used Java type). Phase column matches PROGRESS.md. + +#### Done — built-in scalars / strings / bytes / arrays / collections + +| DSCode | cppcache | CLR | Status | Phase | Notes | +|---:|---|---|:---:|---|---| +| 10 | `CacheableLinkedList` | `LinkedList` | ✅ | 1.3.0 Tier B-2 | wire identical to ArrayList; own adapter branch (not `IList`) | +| 26 | `BooleanArray` | `bool[]` | ✅ | 1.3.0 Tier B-1 | | +| 27 | `CharArray` | `char[]` | ✅ | 1.3.0 Tier B-1 | u16 BE per element (Java `char[]`, not UTF-8) | +| 41 | `NullObj` | `null` | ✅ | 1.2 | inlined in registry (no standalone converter) | +| 42 | `CacheableString` | `string` | ✅ | 1.3.0 Tier A | non-ASCII short; modified UTF-8 (one `StringDataConverter` covers 42/87/88/89) | +| 46 | `CacheableBytes` | `byte[]` | ✅ | 1.3.0 Tier A | VL length + raw bytes; not a valid `TKey` | +| 47 | `CacheableInt16Array` | `short[]` | ✅ | 1.3.0 Tier B-1 | | +| 48 | `CacheableInt32Array` | `int[]` | ✅ | 1.3.0 Tier B-1 | VL boundary unit tests live here, shared with sibling arrays | +| 49 | `CacheableInt64Array` | `long[]` | ✅ | 1.3.0 Tier B-1 | | +| 50 | `CacheableFloatArray` | `float[]` | ✅ | 1.3.0 Tier B-1 | NaN / ±Infinity bit-pattern preserved | +| 51 | `CacheableDoubleArray` | `double[]` | ✅ | 1.3.0 Tier B-1 | | +| 52 | `CacheableObjectArray` | `object[]` | ✅ | 1.3.0 Tier B-2 | hard-coded `"java.lang.Object"` class header; per-element re-entry | +| 53 | `CacheableBoolean` | `bool` | ✅ | 1.2 | walking-skeleton converter | +| 54 | `CacheableCharacter` | `char` | ✅ | 1.3.0 Tier A | UTF-16 code unit, 2-byte BE | +| 55 | `CacheableByte` | `byte` | ✅ | 1.3.0 Tier A | unsigned (.NET convention); wire bit-pattern interop with Java signed byte | +| 56 | `CacheableInt16` | `short` | ✅ | 1.3.0 Tier A | | +| 57 | `CacheableInt32` | `int` | ✅ | 1.2 | walking-skeleton converter | +| 58 | `CacheableInt64` | `long` | ✅ | 1.3.0 Tier A | | +| 59 | `CacheableFloat` | `float` | ✅ | 1.3.0 Tier A | IEEE-754 BE; NaN / ±∞ shape == Java | +| 60 | `CacheableDouble` | `double` | ✅ | 1.3.0 Tier A | IEEE-754 BE | +| 61 | `CacheableDate` | `DateTime` | ✅ | 1.3.0 Tier A | 8-byte ms-since-epoch UTC; Read → `Kind=Utc`; Write rejects `Unspecified` | +| 64 | `CacheableStringArray` | `string[]` | ✅ | 1.3.0 Tier B-1 | registry-injected; per-element 42/87/88/89/41 dispatch | +| 65 | `CacheableArrayList` | `List` / `IList` | ✅ | 1.3.0 Tier B-2 | brought `TypedResultAdapter` + open-generic write fallback | +| 66 | `CacheableHashSet` | `HashSet` / `ISet` | ✅ | 1.3.0 Tier B-2 | canonical decode `HashSet`; null elements travel as DSCode 41 | +| 67 | `CacheableHashMap` | `Dictionary` / `IDictionary` | ✅ | 1.3.0 Tier B-2 | key/value **interleaved** on wire; null key rejected on read (Java HashMap allows, .NET Dictionary doesn't) | +| 69 | `CacheableNullString` | `null` | ✅ | 1.3.0 | read-only null sentinel; handled by `StringDataConverter` | +| 74 | `CacheableStack` | `Stack` | ✅ | 1.3.0 Tier B-2 | **write reverses** to bottom-to-top wire order; adapter re-reverses on the way out | +| 87 | `CacheableASCIIString` | `string` | ✅ | 1.3.0 Tier A | ASCII, u16 length; via `StringDataConverter` | +| 88 | `CacheableASCIIStringHuge` | `string` | ✅ | 1.3.0 Tier A | ASCII, i32 length | +| 89 | `CacheableStringHuge` | `string` | ✅ | 1.3.0 Tier A | non-ASCII huge — switches to **UTF-16 BE** (not modified UTF-8); cppcache parity | + +#### Deferred — clean target exists, awaiting demand or design + +| DSCode | cppcache | CLR | Status | Phase | Notes | +|---:|---|---|:---:|---|---| +| 71 | `CacheableVector` | — | ⏳ | — | Java legacy thread-safe ArrayList; no clean .NET equivalent (forcing `List` would clash with `CacheableArrayList`); revisit if real demand | +| 73 | `CacheableLinkedHashSet` | — | ⏳ | — | .NET lacks an insertion-ordered Set; proper mapping needs a new public type (e.g. `Geode.Client.Collections.OrderedSet`) — public API decision, not wire work | + +#### Planned future phases + +| DSCode | cppcache | CLR | Status | Phase | Notes | +|---:|---|---|:---:|---|---| +| 11 | `Properties` | `IDictionary` | ⏳ | 3 | auth-properties payload (handshake credentials etc.) | +| 17 | `PdxType` | `Geode.Client.Pdx.PdxType` | ⏳ | 2 | PDX type metadata | +| 37 | `CacheableUserData4` | (user `DataSerializable` class) | ⏳ | 2+ | superseded by PDX; only port if a real workload still ships DataSerializable | +| 38 | `CacheableUserData2` | same | ⏳ | 2+ | | +| 39 | `CacheableUserData` | same | ⏳ | 2+ | | +| 93 | `PDX` | user PDX-serialised class | ⏳ | 2 | the main custom-object path | +| 94 | `PdxEnum` | enum | ⏳ | 2 | PDX-encoded enum | + +#### Won't port + +| DSCode | cppcache | Reason | +|---:|---|---| +| 0 | `FixedIDDefault` | wire-layer internal — used as a prefix when serialising `DataSerializableFixedId` objects (EventId / ClientProxyMembershipId / VersionTag / …). NOT a top-level type registered in `SerializationRegistry`; handled inline by the wire builders | +| 1 | `FixedIDByte` | same family | +| 2 | `FixedIDShort` | same family | +| 3 | `FixedIDInt` | same family | +| 4 | `FixedIDNone` | same family | +| 43 | `Class` | sub-marker only — appears inside `CacheableObjectArray`'s class-header bytes (`Class` + the literal `"java.lang.Object"` string); never seen as a top-level Part payload | +| 44 | `JavaSerializable` | Java's native `Serializable` over Geode wire; almost never used in modern deployments; revisit only if a workload requires it | +| 45 | `DataSerializable` | older Geode-specific custom-serialisation; superseded by PDX; same revisit rule as `JavaSerializable` | +| 63 | `CacheableFileName` | rarely used Java type; skip until a workload appears | +| 68 | `CacheableTimeUnit` | rarely used Java enum; skip until a workload appears | +| 70 | `CacheableHashTable` | Java legacy synchronized `Hashtable`; same situation as `Vector` (no clean .NET map + nobody uses it) | +| 72 | `CacheableIdentityHashMap` | identity-equals map; niche on Java side; skip until a workload appears | + +### Serialisation (Phase 2 PDX) + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `Cacheable` / `Serializable` family | `IDataSerializable` | 3 | ⏳ | 2 | Wire format ≠ `ISerializable`; thin contract | +| `PdxType` | `Geode.Client.Pdx.PdxType` | 2 | ⏳ | 2 | | +| `PdxTypeRegistry` | `Geode.Client.Pdx.PdxTypeRegistry` | 2 | ⏳ | 2 | | +| `PdxInstance` | `Geode.Client.Pdx.IPdxInstance` | 2 | ⏳ | 2 | | +| `CacheableString` / `CacheableBytes` etc. | (none) | 1 | 🚫 | — | `string` / `byte[]` direct; codec handles DSCode | + +### Single-hop / partition routing (Phase 4) + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `ClientMetadataService` | `Geode.Client.Internal.ClientMetadataService` | 2 | ⏳ | 4 | | +| `BucketServerLocation` | `Geode.Client.Internal.BucketServerLocation` (record) | 2 | ⏳ | 4 | | +| `ServerLocation` | `Geode.Client.Internal.ServerLocation` (record) | 3 | ⏳ | 1.5 | direct record, no wrapper | + +### Statistics / observability + +| cppcache | C# | Bucket | Status | Phase | Notes | +| --- | --- | --- | --- | --- | --- | +| `Statistics` framework | `System.Diagnostics.Metrics.Meter` | 1 | 🚫 | — | | +| `PoolStats` | thin wrapper that registers cppcache-named counters into a `Meter` | 3 | ⏳ | 1.5 | | +| `LoggingMacros` / `LOGFINE` | `Microsoft.Extensions.Logging.ILogger` | 1 | 🚫 | — | | + +### Bucket 1 — BCL replacements (no port needed) + +| cppcache | .NET / BCL replacement | Notes | +| --- | --- | --- | +| `boost::asio::tcp::socket` | `System.Net.Sockets.Socket` / `NetworkStream` | | +| `boost::asio::ssl::stream` | `System.Net.Security.SslStream` | | +| `boost::asio::io_context` + workers | `Task` + `async`/`await` | | +| `std::thread` / `boost::thread` | `Task.Run` | | +| `std::mutex` / `recursive_mutex` | `lock` / `SemaphoreSlim` | | +| `std::condition_variable` | `Channel` / `SemaphoreSlim` | | +| `std::atomic` | `Interlocked` | | +| `std::shared_ptr` | GC | | +| `std::chrono::duration` | `TimeSpan` | | +| `ExpiryTaskManager` + `FunctionExpiryTask` | `PeriodicTimer` | | +| cppcache internal `Task` worker class | `Task.Run` + cancellable loop | name collides with BCL; the cppcache class is internal | +| `LoggingMacros` / `LOGFINE` etc. | `Microsoft.Extensions.Logging.ILogger` | also cross-listed under §Statistics / observability | +| `Statistics` framework | `System.Diagnostics.Metrics.Meter` / EventCounters | also cross-listed under §Statistics / observability | +| `Xerces-C` (cache.xml parser) | cut entirely | per Configuration policy | +| `apache::geode::client::Properties` | `IDictionary` | | + +### Bucket 3 — thin wrappers (BCL covers most, wrap the gap) + +cppcache classes where the BCL has the engine but is missing some +semantics. Wrap **only enough** to add the missing bit; do not +rebuild the whole cppcache class. Domain sections above hold the +per-class status / phase rows; this table is the design-decision +view (what BCL is missing + wrap strategy). + +| cppcache | What BCL is missing | Wrap strategy | +| --- | --- | --- | +| `ConnectionQueue` (FIFO + condvar + size cap + timed get) | `Channel` lacks "wait up to T then create new" | thin wrapper around `Channel` exposing `TryGetWithTimeoutAsync` | +| `synchronized_map` | `ConcurrentDictionary` has no iterate-with-lock | **don't wrap** — use `ConcurrentDictionary` + snapshot where needed | +| `Cacheable` / `Serializable` family | `ISerializable` doesn't match PDX wire format | introduce `IDataSerializable` interface (Phase 2) | +| `PoolStats` (named counters + sampler) | `Meter` naming / sampling differs | thin wrapper that registers cppcache-named counters into a `Meter` | +| `CacheableString` / `CacheableBytes` | `string` / `byte[]` already exist | **don't wrap** — handle DSCode tag in the codec only | +| `ServerLocation` (host + port + version) | nothing equivalent | **don't wrap** — define a record `ServerLocation(...)` directly | + +--- + +## 3. Exception hierarchy 🌐 + +cppcache exposes 58 exception types in +[`ExceptionTypes.hpp`](https://github.com/apache/geode-native/blob/develop/cppcache/include/geode/ExceptionTypes.hpp). +Per the three-bucket rule, many map to BCL exceptions directly (bucket 1); +the rest are Geode-runtime semantics that need their own `GeodeException` +subclass (bucket 2). MVP entry exception is the base +`Geode.Client.GeodeException`. + +### Bucket 1 — BCL replacement (do not port) + +| cppcache | .NET / BCL replacement | We use it? | +| --- | --- | --- | +| `IllegalArgumentException` | `ArgumentException` / `ArgumentNullException` / `ArgumentOutOfRangeException` | ✅ | +| `IllegalStateException` | `InvalidOperationException` | ✅ | +| `TimeoutException` | `System.TimeoutException` | ✅ | +| `FileNotFoundException` | `System.IO.FileNotFoundException` | — no site yet | +| `InterruptedException` | `OperationCanceledException` (we drive cancellation via `CancellationToken`) | ✅ | +| `UnsupportedOperationException` | `NotSupportedException` | ✅ | +| `ConcurrentModificationException` | `InvalidOperationException` (collection modified during enum) | — no site yet | +| `ClassCastException` | `InvalidCastException` | — no site yet | +| `NullPointerException` | `NullReferenceException` / `ArgumentNullException` | ✅ | +| `OutOfMemoryException` | `System.OutOfMemoryException` (runtime-emitted) | — | +| `OutOfRangeException` | `ArgumentOutOfRangeException` / `IndexOutOfRangeException` | ✅ | +| `BufferSizeExceededException` | `System.IO.InvalidDataException` (or `ArgumentOutOfRangeException`) | ✅ | +| `AssertionException` | `Debug.Assert` (non-exception) or `InvalidOperationException` | — | +| `NotOwnerException` | `SynchronizationLockException` | — no site yet | +| `MessageException` | `System.IO.InvalidDataException` (wire-format decode) | ✅ several sites | +| `GeodeIOException` | `System.IO.IOException` | ✅ | +| `UnknownException` | generic `Exception` (no specific type) | — | +| `CacheXmlException` | N/A — we do not parse `cache.xml` | — | + +### Bucket 2 — Geode-runtime, port as `GeodeException` subclass + +Status legend: ✅ implemented, ⏳ planned (later phase), ❌ not yet built but useful now. + +| cppcache | Our class | Status | Phase | Notes | +| --- | --- | --- | --- | --- | +| `GeodeException` (base) | `Geode.Client.GeodeException` | ✅ | 0 | Base for all Geode-runtime exceptions. | +| `NoAvailableLocatorsException` | `Geode.Client.NoAvailableLocatorsException` | ✅ | 1.5 | Fatal-client failure in pool retry loop. | +| `CacheServerException` | `Geode.Client.CacheServerException` | ✅ | 1.5 | Thrown when server replies `MessageType.Exception`. | +| `AuthenticationFailedException` | `Geode.Client.AuthenticationFailedException` | ✅ | 1.5 (declared) / 3 (thrown) | Auth fatal-client; thrown in Phase 3. | +| `NotConnectedException` | `Geode.Client.NotConnectedException` | ✅ | 1.5 | Declared; 3 current throwers via text-coded `GeodeException` still pending migration. | +| `AuthenticationRequiredException` | `Geode.Client.AuthenticationRequiredException` | ✅ | 1.5 (declared) / 3 (thrown) | Pairs with the auth triplet; server requires auth, client missing creds. | +| `NotAuthorizedException` | `Geode.Client.NotAuthorizedException` | ✅ | 1.5 (declared) / 3 (thrown) | Auth pass but no permission for the op. | +| `AllConnectionsInUseException` | `Geode.Client.AllConnectionsInUseException` | ✅ | 1.5 (declared) | `MaxConnections` enforcement待辦; thrown once cap check lands in `CreatePoolConnectionAsync`. | +| `AlreadyConnectedException` | _undecided_ | ⏳ | 12 | Durable client reconnect collision. | +| `CacheProxyException` | _undecided_ | ⏳ | — | Proxy-layer failure. | +| `CacheExistsException` | _undecided_ | ⏳ | — | Duplicate cache construction. | +| `CacheClosedException` | _undecided_ | ⏳ | — | Op on a closed cache. | +| `RegionExistsException` | _undecided_ | ⏳ | — | Duplicate region creation. | +| `RegionCreationFailedException` | _undecided_ | ⏳ | — | Region build error. | +| `RegionDestroyedException` | _undecided_ | ❌ | 1.5 | Already triggered in fresh-conn race tests via text-coded `CacheServerException`. | +| `EntryDestroyedException` | _undecided_ | ⏳ | — | Entry was destroyed during op. | +| `EntryNotFoundException` | _undecided_ | ⏳ | — | Explicit "must exist" semantics. | +| `EntryExistsException` | _undecided_ | ⏳ | — | `PutIfAbsent` collision. | +| `KeyNotFoundException` | _undecided_ | ⏳ | — | Similar to `EntryNotFoundException`. | +| `WrongRegionScopeException` | _undecided_ | ⏳ | — | Scope mismatch. | +| `CacheWriterException` | _undecided_ | ⏳ | 12 | Listener / writer hook failure. | +| `CacheLoaderException` | _undecided_ | ⏳ | 12 | | +| `CacheListenerException` | _undecided_ | ⏳ | 12 | | +| `LeaseExpiredException` | _undecided_ | ⏳ | — | Lock lease expiry. | +| `StatisticsDisabledException` | _undecided_ | ⏳ | — | | +| `CqException` (+ 4 variants) | _undecided_ | ⏳ | 2 | CQ family (`CqClosed`, `CqQuery`, `CqExists`, `CqInvalid`). | +| `FunctionException` | _undecided_ | ⏳ | 11 | Function execution. | +| `TransactionException` | _undecided_ | ⏳ | 11 | | +| `RollbackException` | _undecided_ | ⏳ | 11 | | +| `InvalidDeltaException` | _undecided_ | ⏳ | 12 | | +| `DuplicateDurableClientException` | _undecided_ | ⏳ | 12 | | +| `QueryException` | _undecided_ | ⏳ | 1.4 | Currently surfaces as text-coded `GeodeException`. | +| `NoSystemException` | _undecided_ | ⏳ | — | | +| `FatalInternalException` | _undecided_ | ⏳ | — | Invariant violation. | +| `InitFailedException` | _undecided_ | ⏳ | — | Startup failure. | +| `ShutdownFailedException` | _undecided_ | ⏳ | — | Destroy failure. | +| `DiskFailureException` | _undecided_ | ⏳ | — | Server-side; client just reports. | +| `DiskCorruptException` | _undecided_ | ⏳ | — | Server-side. | +| `GeodeConfigException` | _undecided_ | ⏳ | — | | + +**Summary:** 18 BCL-covered + 40 Geode-runtime = 58 total. 8 of the 40 are +implemented: `GeodeException` (base), `NoAvailableLocatorsException`, +`CacheServerException`, `AuthenticationFailedException`, +`AuthenticationRequiredException`, `NotAuthorizedException`, +`NotConnectedException`, `AllConnectionsInUseException`. The auth triplet +is complete; with `NoAvailableLocatorsException` they fully cover cppcache +`isFatalClientError`. `RegionDestroyedException` is the next obvious add +(currently surfaces via text-coded `CacheServerException` in fresh-conn +race tests). The rest follow their respective phases. + +--- + +## How to use this file + +- **Before coding a new cppcache class**: add a row in the right + section, mark its bucket and status (usually 🔨 or ⏳), pick a + visibility (🌐 / 🔒). +- **When status changes**: flip the symbol, optionally bump notes. +- **When a row turns out to be bucket 1**: leave the row, change + status to 🚫, and move to the bottom bucket-1 table for the + archaeology trail. +- **Phase column**: matches PROGRESS.md phase numbers. diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..a89d4d4 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,293 @@ +# GeodeSharp — Implementation Progress + +> Living progress tracker. Update at the start and end of each phase / sub-phase. +> `CLAUDE.md` is the unchanging plan; this file is the changing state. +> [PORTING.md](PORTING.md) is the cppcache ↔ C# class mapping table (finer-grained per-class status). +> +> **New session / phase handoff:** read this file before exploring the codebase. + +--- + +## Status at a glance + +| Phase | Status | 詳細 | +|---|---|---| +| Phase 1 — MVP (Connect / CRUD / Bulk / Query / Pool / Locator / Failover) | done | [PROGRESS1.md](PROGRESS1.md) | +| Phase 2 — 自訂物件 / HA / 訂閱 / 進階查詢 | next | [PROGRESS2.md](PROGRESS2.md) | +| Phase 3 — Security + Function execution | pending | — | +| Phase 4 — Performance + Partitioning | pending | — | +| Phase 5 — Code hygiene / pruning | queued | (PROGRESS.md 下方) | +| DI surface reshape (`IGeodeCacheFactory` + extensions) | planned, not started | — | + +**Entry point for the next session:** Phase 2 walking skeleton. Phase 1 MVP +is closed out (Connect / CRUD / Bulk / Query / Pool-Locator-Failover); Phase +1.5 polish (`PoolStatistics` last 7 catalogue fields, `TcrPoolEndPoint` +migration, Auth-trio throw sites, TCCM dead-code, options-tree pruning) +deferred to Phase 5 / pre-release audit. Focus shifts to **sketching the +Phase 2+ feature surface** (subscription / CQ, HA / redundancy, PDX custom +objects, delta propagation, security, function execution, PR single-hop) +as top-level NIE stubs end-to-end before drilling into details — walking +skeleton first, polish later. + +--- + +## Feature roadmap + +### Phase 1 (MVP — production-ready client) + +- Connect +- Single-key CRUD (Put / Get / Remove / ContainsKey) +- Bulk ops (PutAll / GetAll / RemoveAll) +- Clear +- Invalidate +- Region convenience queries (ExistsValue / SelectValue) +- Built-in type serialization (incl. collections: List, Dictionary, array, HashSet) +- OQL queries (`SELECT *`, `SELECT COUNT(*)`, and multi-column projection + `SELECT field1, field2` — pulled forward from Phase 2 because the + result-decoder `StructSet` branch shares a code path with `ResultSet`; + deferring would leave a half-built switch that silently returns garbage on + projection queries) +- Connection pool +- Locator discovery +- Server failover / automatic reconnect + +### Phase 2 (custom objects + advanced query) + +- Custom-object serialization (PDX) +- Interop with the Java client +- PdxInstance (read fields without full deserialization) +- Continuous Query (server-push subscription) +- Transactions (Begin / Commit / Rollback) + +### Phase 3 (security + compute) + +- Authentication (username/password, custom auth provider) +- TLS / mTLS +- Function execution (server-side) + +### Phase 4 (performance + partitioning) + +- Delta propagation (ship only changed fields) +- Partition resolver (custom colocation) + +### Phase 5 (code hygiene / pruning) + +Dead-code removal, options-tree pruning, deferred test work — items +that are non-functional cleanup, scoped after the feature phases. + +### Not implementing + +- **cache.xml** — replaced by `appsettings.json` + `IOptions`. +- **Sub-regions** — Geode itself discourages them. +- **Sync API** — async only. +- **Cache listener / loader / writer** — niche; easier server-side in Java. +- **Region expiration / eviction** — managed server-side; the client stays out. + +--- + +## Wire protocol summary + +### Frame layout (all big-endian) + +``` ++------------------+------------------+------------------+------------------+ +| MessageType i32 | MessageLength i32| NumParts i32 | TransactionId i32| ++------------------+------------------+------------------+------------------+ +| EarlyAck u8 | | ++------------------+--------------------------------------------------------+ +| Part 1, Part 2, ... NumParts parts | ++----------------------------------------------------------------------------+ + +Part: ++------------------+----------+--------+-------------+ +| PartLength i32 | IsObject | Type | Payload | +| | u8 | u8 | (PartLen B) | ++------------------+----------+--------+-------------+ +``` + +### Handshake (the easiest place to slip) + +Handshake **does not** use the standard frame format — it is an ad-hoc byte +sequence. Translate `cppcache/src/TcrConnection.cpp::sendHandshakeForServer` +byte-by-byte. **Do not write it from memory.** + +### MessageType + +Canonical list is the `Geode.Client.Protocol.MessageType` enum at +`src/Geode.Client/Protocol/MessageType.cs` (mirror of cppcache +`cppcache/src/TcrMessage.hpp`). Which values land in which sub-phase is tracked +by the phase sections below. + +--- + +## Configuration + +cppcache uses two files: `.ini` (`SystemProperties`) and `cache.xml` (region / +pool declarations, parsed by Xerces). **We drop both and use the .NET +`IOptions` pattern** — `appsettings.json` + `IConfiguration` bind straight +to record / class options. **No `cache.xml`. No `.ini`.** + +### Options policy + +1. **Mirror first, prune later.** When porting a cppcache config knob, **copy + every property over** (one C# property per cppcache key, defaults matching + cppcache constants). Pruning happens once and late — roughly end of Phase + 1.5 or before the first NuGet release — when we audit which properties + have a code path that actually reads them. Don't judge at porting time + which property "looks unused"; the cppcache audit window stays open until + the .NET pool design settles. + +2. **Document semantics on the property, not in side notes.** Each options + property's XML doc records what was learned from reading cppcache: which + file consumes it, what it actually drives (e.g. `SO_SNDBUF`, expiry-task + interval, per-endpoint cap), whether it is pool-level / connection-level + / endpoint-level, and any platform quirks (e.g. `#ifdef __linux`). The doc + is the audit trail — six months later, someone reviewing the property + should not need to re-read cppcache to understand it. + +3. **Don't invent JSON schema for things not yet implemented.** Concrete JSON + shape is decided per phase against cppcache `SystemProperties` semantics; + don't write a target schema for code that doesn't exist yet. + +--- + +## Public API sketch (DI-first) + +```csharp +// Registration +builder.Services.AddGeodeClient(builder.Configuration.GetSection("Geode")); + +// Use +public class OrderService(IGeodeCache cache) +{ + private readonly IRegion _orders = cache.GetRegion("orders"); + public Task SaveAsync(string id, byte[] payload, CancellationToken ct) + => _orders.PutAsync(id, payload, ct); +} +``` + +Current interface shape lives in `src/Geode.Client/` — `IGeodeCache`, `IRegion` +/ `IRegion` (typed overlay with `where TKey : IEquatable`), +`IQueryService`, `IQuery`. Source is the source of truth; no parallel +interface list is maintained here. + +**Important:** the MVP does not support `cache.xml` and does not create +regions. A DBA pre-creates regions with `gfsh` (`gfsh create region +--name=test --type=REPLICATE`); the client is a proxy. + +--- + +## In progress + +_Phase 1 is closed out. Phase 2+ walking skeleton work has not started yet +— next session begins by sketching the Phase 2 feature surface as +top-level NIE stubs._ + +--- + +## Completed + +Phase 1 (MVP 階段) 詳細紀錄已搬到 [PROGRESS1.md](PROGRESS1.md)。 + +--- + +### Phase 0 — DI + entry interfaces + +Entry interfaces: `IGeodeCache` / `IRegion` / +`IQueryService` / `IQuery` / `IGeodeCacheFactory`. `GeodeException` +(BCL exceptions for transport / API misuse; `GeodeException` for Geode +protocol failures). `GeodeClientOptions` + sub-options (full cppcache +mirror, schema to be pruned later). `AddGeodeClient` three overloads +(host config / external IConfiguration / Action delegate) × named & +unnamed. `IGeodeCacheFactory` + `GeodeCacheFactory` (per-cache +`AsyncServiceScope`, `Lazy` race guard, cascading async dispose). +`GeodeCache.EnsureInitializedAsync` uses +`Lazy(ExecutionAndPublication)`. 130 unit tests green, 0 build +warnings. + +Carry-over (intentional, not gaps): `IRegion` / `IQueryService` / +`IQuery` are empty shells (methods filled in Phase 1.2 / 1.4); +`GeodeClientOptions` is the full cppcache `SystemProperties` mirror +(incl. `LogOptions` / `StatisticsOptions` / `HeapOptions` / +`CacheOptions` / `ThreadPoolSize` / `EnableChunkHandlerThread`) per +the "mirror then prune" policy, pruning happens late Phase 1.5 / +pre-release; each sub-options class needs xmldoc filled in with +cppcache origin (consumer file / semantics / platform constraints) per +CLAUDE.md "Document semantics on the property"; no `AuthOptions` yet +(Phase 3 security). + +--- + +## Phase 2 — 自訂物件、HA、訂閱、進階查詢 + +Phase 2 詳細請見 [PROGRESS2.md](PROGRESS2.md)。 + +範圍:PDX 自訂物件、訂閱通道 / Continuous Query、HA / 冗餘、 +Transactions。從 Phase 1.5 推來的 Locator follow-ons +(`getEndpointForNewCallBackConn` 等)也在那邊。 + +## Phase 5 — Code hygiene / pruning + +Non-functional cleanup queued behind the feature phases. None block +shipping; they reduce surface area / dead code once the feature work +is mature enough to know what survives. + +### Options-tree prune (audit complete; details in commit history) + +- **`HeapOptions`** — server-side concept (`heap-lru-limit` / + `heap-lru-delta` / `tombstone-timeout`), no client analogue; + referenced only by `GeodeClientOptions.Heap` + clone/validate. +- **`PoolOptions` system-properties layer (5 fields)** — + `ConnectionPoolSize` (per-EP cap not implemented), + `ConnectWaitTimeout` (Linux EPIPE workaround irrelevant under .NET + async sockets), `MaxSocketBufferSize` (never applied to socket), + `ShuffleEndpoints` (our DM uses `Random.Shared.Next` at + construction), `BucketWaitTimeout` (Phase 4+ PR routing). +- **`GeodeClientOptions` root (2 fields)** — `ThreadPoolSize` and + `EnableChunkHandlerThread` (xmldoc admits both are "very likely + no-ops" under .NET; the latter has one stale TODO marker in + `ThinClientBaseDM.cs:66`). +- **`CachePoolOptions` per-pool layer (4 fields)** — + `SocketBufferSize` (duplicate of `PoolOptions.MaxSocketBufferSize`), + `Subscription{AckInterval,MessageTrackingTimeout,Redundancy}` + (Phase 2+ subscription — re-add when CQ work starts). +- **`CacheOptions` cache layer (2 fields)** — `RedundancyLevel` + (Phase 2+ subscription redundancy), `Version` (pinned `"1.0"`, + never validated). +- **Kept (consumer scheduled for a known phase, do NOT prune):** + `CachePoolOptions.MultiuserAuthentication` (Phase 3, + `_isMultiUserMode` already reads it), `SubscriptionEnabled` + (Phase 2+ `ThinClientPoolHADM` factory selector), + `ThreadLocalConnections` (Phase 1.5 sticky factory selector), + `PingInterval` (deliberately nullable for the two-layer + `xmlPool.PingInterval ?? options.Pool.PingInterval` fallback). + +### Lifecycle dead-code + +- **TCCM dead-code removal** — the inventory is done but nothing has + moved. Drop the 6 NIE methods + their dead fields, simplify + `InitAsync` (drop the `isPool` parameter), rewrite the class XML doc + to reflect the real role ("endpoint registry + durable flag holder"). + ~80 lines deleted, ~10 changed. +- **Release TCCM endpoint refs** + (`ConnManager.RemoveRefToTcrEndpointAsync`) — currently piggybacks on + cache-scope dispose cascade. + +### Deferred tests + +- **`PutInQueueAsync` tests** — `_isDestroyed` guard + (cppcache `ConnectionQueue::put` `closed_` branch, + `ConnectionQueue.hpp:62-67`) is implemented but untested. Happy path + is implicitly covered by every back-to-back op in + `CacheConnectionIntegrationTests` / `RegionCrudIntegrationTests` + (conn enqueued by op #1, picked up by op #2). The destroyed-guard + itself is structurally unreachable from public API + (`SendRequestToEndpointAsync` rejects on `_isDestroyed != 0` at the + top) — only fires in a race window mid-`SendRequestToEndpointAsync`. + Deterministic test needs either (a) wire-response orchestration in + integration test to pause `SendAsync` while `DestroyAsync` races, or + (b) visibility relaxation + DI-tree scaffolding + spy on a `sealed` + `TcrConnection`. Both cost-ineffective relative to the 5-line guard. + Revisit when `PoolDisconnects` Meter or socket-leak tooling lands + (then the guard would have an observable counterpart). Source: inline + comment in `ThinClientPoolDM.PutInQueueAsync` flags this deferral. diff --git a/PROGRESS1.md b/PROGRESS1.md new file mode 100644 index 0000000..ce430b2 --- /dev/null +++ b/PROGRESS1.md @@ -0,0 +1,167 @@ +# Phase 1 — MVP 階段紀錄 + +> Phase 1 (1.1 - 1.5) 走的是 walking skeleton — connect / CRUD / bulk / +> query / pool / locator / failover 一路打通,對接真實 Apache Geode +> 叢集。本檔將整個 Phase 1 視為一個整體,不再細分子階段,只列已完 +> 成與未完成項目。詳細歷史請看 git log。 + +--- + +## 已完成的項目 + +- **Cache 入口 / DI 接線** — `IGeodeCache` lifecycle + (`EnsureInitializedAsync` / `CloseAsync`);handshake + auth-mode-NONE; + `services.AddGeodeClient(...)` 三種 overload(host config / 外部 + `IConfiguration` / `Action` delegate)× 具名與不具名; + `IGeodeCacheFactory` + per-cache `AsyncServiceScope` + 連鎖 async + dispose;`IOptions` 配置。 + +- **單筆 KV 操作** — `Put` / `Get` / `Remove` / `ContainsKey` 完整 + round-trip,對接 server 上的真實 region。 + +- **批次 KV 操作** — `PutAll` / `GetAll` / `RemoveAll` / `Clear` / + `Invalidate`,單一往返處理多筆。 + +- **Region 便利查詢** — `ExistsValue` / `SelectValue` 包裝 OQL,單 + predicate 場景的甜蜜路徑。 + +- **內建型別序列化** — `byte[]` / `string`(modified-UTF-8 + UTF-16 大 + 字串)/ 整型 / 浮點 / `DateTime` / `bool` / `decimal`;集合型別 + `List` / `Dictionary` / `HashSet` / 陣列,皆對齊 Java + DSCode 來回不損失。 + +- **OQL 查詢** — `IQueryService.NewQuery(oql)` 介面 + `IQuery`; + 支援 `SELECT *` / `SELECT COUNT(*)` / 多欄位投影 + (`SELECT a, b` → `QueryStruct`);`ChunkedQueryResponse` 完整 + chunked 解碼;NewQuery 型別保護(只接 BCL 與註冊型別,擋掉 PDX 與 + ORM 套用)。 + +- **連線池** — `ThinClientPoolDM` 含 `_opConnections` idle queue、 + `MinConnections` / `MaxConnections` / `IdleTimeout` / + `LoadConditioningInterval` / `FreeConnectionTimeout`;pool 層 + cap + 每個 endpoint 層 cap(雙層 slot semaphore)。 + +- **Locator 探索** — `ThinClientLocatorHelper` 處理 + `LocatorListRequest` + `ClientConnectionRequest`;背景 locator-list + 刷新 loop;multi-locator + multi-server fixture 整合測試通過。 + +- **Server failover** — `SendSyncRequestCoreAsync` 內 DM-level retry + frame(Step A-G 對齊 cppcache);transport error 第一輪分類 + (`IsRetryableTransportError`);`RemoveEPFromMetadataIfError` 接在 + catch path;`ServerFailoverIntegrationTests` 用 `gfsh stop server` + 測過真實 server 倒掉場景。 + +- **連線生命週期** — ping loop(periodic timer 驅動)、conn-management + loop(clean-stale + restore-min)、sticky-conn 骨架、endpoint 健康 + 監控(`SetConnected` 透過 `_distMgrs` 廣播 inc/dec)。 + +- **可觀測性** — cppcache `PoolStatistics` 27 個 catalogue 欄位完成 + 20 個;額外加非 catalogue 的 `PingSweepTime` / `EndpointPingTime` + ping 監測,以及 `ConnectedServers`(對外暴露 cppcache 內部 + `connected_endpoints_` 計數);每個 locator RPC 有 + `ActivitySource` span;ObservableGauge / Histogram / Counter 三種 + instrument 混用,跟 OpenTelemetry / Prometheus 相容。 + +- **測試基礎建設** — xUnit v3 + FluentAssertions;Testcontainers + + Podman + `apachegeode/geode` 容器;`MeterCapture` test helper 抓 + instrument 量測;107 個整合測試(102 過、5 跳過、0 失敗)。 + +--- + +## 未完成的項目 + +- **PoolStatistics 剩 7 個 catalogue 欄位** — `subscriptionServers` + (Phase 2+ HA)、`messagesBeingReceived`(Phase 2+ notification + channel)、`processedDelta*` × 3(Phase 2+ delta propagation)、 + `queryExecutions` / `queryExecutionTime`(Phase 1.4 路徑已存在,只是 + stat 沒接);`PoolDisconnects` 未在每個 close site 都接到。 + +- **`TcrEndpoint` / `TcrPoolEndPoint` 階層遷移** — 空 skeleton 子類已 + 建(commit `7be777c`),但真實切換沒完成。要做到 cppcache 全口徑 + 「每個 pool 自己一份 endpoint 實例」需要把 endpoint 建構從 + TCCM 搬到 pool 自己的 `addEP`、`_distMgrs` list 改回單一 `_baseDM`、 + `conn.PoolDM` 可從 `endpoint.GetPoolHADM()` 推導。連帶問題:目前 + `conn.PoolDM` 在 handshake 完成後才 set,handshake 階段的 bytes 不算 + 進 `ReceivedBytes`。 + +- **Auth-trio 真實 throw site** — `AuthenticationFailedException` / + `AuthenticationRequiredException` / `NotAuthorizedException` class + 存在但無人 throw。Handshake step 9(`acceptanceCode != REPLY_OK` + 分支)是預定位置,等 Phase 3 security 做 cppcache `AUTH_REQUIRED` / + `AUTH_FAILED` 對應。 + +- **Fresh-conn race 真正改進** — server-side `ClientHealthMonitor` + 註冊延遲(cold JVM 5-100ms);測試靠 + `FreshConnectionSettleDelay = 3s` 繞過。Memory + `geode-fresh-conn-race.md` 紀錄這是 server 端時序問題,client 側 + 改進可行性低。 + +- **TCCM dead-code 清理** — 6 個 NIE method + dead field + + `InitAsync` 的 `isPool` ctor 參數可以刪;盤點完(約 80 行刪、 + 10 行修改),動工未做。 + +- **Options-tree 修剪** — 已知無 consumer 的欄位:`HeapOptions` + 整個 class、`PoolOptions` system-properties 層 5 個欄位、 + `GeodeClientOptions` 根層 2 個欄位、`CachePoolOptions` 4 個欄位、 + `CacheOptions` 2 個欄位。Audit 完成,刪除動作未做。 + +- **PR single-hop / `ClientMetadataService`** — placeholder 在, + `RemoveBucketServerLocation` 是 no-op stub;真實實作要等 Phase 4 + PR(partition routing)。 + +- **Ping timeout tolerance** — cppcache 容忍一次 timeout 才翻 + `connected` bit;我們任何例外都直接翻。要等完整 `GfErrType` + taxonomy port 才能精確區分 transport timeout 與 server returned + exception。 + +- **Non-pool 路徑去留決定** — `ThinClientDistributionManager` / + `TcrDistributionManager` / `TcrHADistributionManager` 整個 sub-tree + 刻意未 port(memory `pool-only-no-non-pool.md`)。Pre-release audit + 時決定:真實作或徹底丟掉結構性 placeholder。 + +- **DI surface reshape** — `IGeodeCacheFactory` + extension 重整, + planned 但未開工。 + +- **PORTING.md 持續更新** — cppcache 對映表的維護,新加 class + (例如 `TcrPoolEndPoint`)的狀態欄、Bucket 1 / Bucket 3 對映新增。 + +--- + +## 設計決策與已知偏離 + +Phase 1 階段做了幾個跟 cppcache 偏離的設計選擇,記錄供 Phase 2+ +或 pre-release audit 時回顧: + +- **Endpoint 階層收一個** — cppcache 拆 `TcrEndpoint`(非 pool 基類) + 與 `TcrPoolEndPoint`(pool 子類,持單一 `m_dm`);我們合一,改用 + `_distMgrs` list 支援多 pool 共用 endpoint。空殼 `TcrPoolEndPoint` + 已建,migration 是未完成項目。 + +- **Endpoint 跨 pool 共用** — cppcache 每個 pool 各自一份 endpoint + 實例(同 host:port 多個 instance);我們 TCCM 全域唯一一份,多 pool + 共用。這是上一條的延伸,代價是「endpoint 的擁有 DM 是誰」需要 + list 處理,失去 1:1 確定性。 + +- **`SetConnected` 廣播 vs 單通知** — cppcache 只通知 `m_baseDM`,我 + 們走 `_distMgrs` 全廣播。代價是 callee(`Inc/DecConnectedEndpoints`) + 必須 lock-free、non-reentrant。 + +- **Non-pool 路徑收掉** — cppcache 有完整非 pool sub-tree;我們強制 + pool 模式,使用者要無 pool 就配 default pool。對齊現代 Geode 推薦 + 慣例。 + +- **Stats Counter+Time pair 合成單一 Histogram** — cppcache 多處用兩 + 個獨立欄位(IntCounter +「次數」+ LongCounter ns「累計時間」), + 我們合成單一 `Histogram`(秒)。`.Count` = 原次數、`.Sum` + = 原累計時間。已套用:`LocatorListRequestTime`、 + `ClientConnectionRequestTime`、`ConnectionWaitTime`、`ClientOpTime`。 + +--- + +## Phase 1 階段交付的測試覆蓋率 + +- 161 個 unit 測試(`Geode.Client.Tests`) +- 107 個整合測試(`Geode.Client.IntegrationTests`)— 102 過、5 跳過 + (3 個調查中的 RegionDestroyed flake、2 個診斷用 dump)、0 失敗 +- 整合測試對接的真實環境:`apachegeode/geode` 容器(via Podman)、 + multi-locator + multi-server fixture、`gfsh` 動態操作 server 起停 diff --git a/PROGRESS2.md b/PROGRESS2.md new file mode 100644 index 0000000..5b25712 --- /dev/null +++ b/PROGRESS2.md @@ -0,0 +1,161 @@ +# Phase 2 — 自訂物件、HA、訂閱、進階查詢 + +> Phase 2 的範圍:**讓 client 從「能 KV」進展到「能跟真實業務系統 +> 接軌」**。Phase 1 走完 walking skeleton 之後,本階段先以 +> **top-level NIE stub** 形式把功能面定型(端到端骨架接通),再回頭 +> 細化單一功能。 +> +> 主檔 [PROGRESS.md](PROGRESS.md);Phase 1 紀錄 +> [PROGRESS1.md](PROGRESS1.md);類別對映表 +> [PORTING.md](PORTING.md)。 + +--- + +## 範圍 (Scope) + +Phase 2 拆四個子階段,**順序依 walking skeleton 原則**(top-down, +user-facing 功能先,robustness 後補): + +- **2.1 PDX 自訂物件序列化** — 跨語言(.NET ↔ Java)欄位級序列化、 + `PdxInstance`(免反序列化讀欄位)。對齊 cppcache `PdxType` / + `PdxTypeRegistry` / `PdxInstanceImpl`。 +- **2.2 訂閱通道 / Continuous Query** — server-push 通知連線、 + `registerInterest`、CQ。v1 不耐 server 失敗(等價於 Phase 1.1 + single-server connection),斷線恢復推到 2.3。對齊 cppcache + `TcrEndpoint::registerDM(clientNotification=true)`、 + `m_notifyConnection` / `m_notifyReceiver`。 +- **2.3 HA / 冗餘** — primary / secondary server 角色、durable client、 + `RedundancyManager`、訂閱通道斷線 reconnect + 事件 replay。把 2.2 + v1 升級成 production-grade。 +- **2.4 Transactions** — `Begin` / `Commit` / `Rollback`。對齊 cppcache + `CacheTransactionManagerImpl`。 + +每個子階段都先做 walking skeleton entry point + NIE,再分批細做。 + +--- + +## Walking skeleton 待建項目 + +每個 entry point 必須:**(a) public surface 編譯得過、 +(b) 進入點丟 `NotImplementedException` 並標 Phase 對應、 +(c) cppcache 對應 class / method 在 xmldoc 引用**。 + +### Phase 2.1 — PDX 自訂物件序列化 + +- [ ] `IPdxSerializable` interface(`ToData(IPdxWriter)` / + `FromData(IPdxReader)`) +- [ ] `IPdxWriter` / `IPdxReader` interface(用 GfErrType-free 風格, + 回傳 `void` / `T`) +- [ ] `PdxType` / `PdxField` 內部 metadata class +- [ ] `PdxTypeRegistry` service(typeId ↔ schema 映射) +- [ ] `IPdxInstance` public interface(`HasField` / `GetField` / + `CreateWriter`) +- [ ] `SerializationRegistry.RegisterPdxType()` 註冊入口 +- [ ] `TcrMessageBuilder` 對 PDX 物件的 `WritePart` 路徑 + +### Phase 2.2 — 訂閱通道 / Continuous Query + +> v1 不要求耐 server 失敗 — server 死掉訂閱也死掉,等價於 Phase 1.1 +> 的 single-server connection。Reconnect / 事件 replay 屬於 Phase 2.3 +> HA 範疇,留 NIE 點。 + +- [ ] `IRegion.RegisterInterestAsync(...)` 入口 +- [ ] `IRegion.UnregisterInterestAsync(...)` 入口 +- [ ] `IRegion.SubscribeAsync(IRegionListener)` + 入口(`IAsyncDisposable` 解訂) +- [ ] `IRegionListener` interface(`OnCreated` / + `OnUpdated` / `OnDestroyed` / `OnInvalidated`) +- [ ] `IQueryService.NewCqAsync(string oql, ICqListener)` 入口 +- [ ] `ICqListener` interface +- [ ] `TcrEndpoint` 的 `notificationChannel` / `notifyReceiver` task + (cppcache `m_notifyConnection` / `m_notifyReceiver`) +- [ ] `CreateNewConnectionAsync(isClientNotification: true)` 路徑 + (目前 NIE) + +### Phase 2.3 — HA / 冗餘 + +> 把 Phase 2.2 v1 的「斷線就死」升級成「斷線 reconnect + 事件 replay」。 + +- [ ] `ThinClientPoolHADM` 從骨架升級成真實實作 +- [ ] `RedundancyManager` 服務(主備角色、reconnect 時保留訂閱) +- [ ] `CachePoolOptions.SubscriptionRedundancy` 真實 consumer + (目前 Phase 5 prune 清單上) +- [ ] `CachePoolOptions.SubscriptionAckInterval` 真實 consumer +- [ ] `CachePoolOptions.SubscriptionMessageTrackingTimeout` 真實 + consumer +- [ ] Durable client ID / durable timeout 路徑 + +### Phase 2.4 — Transactions + +- [ ] `ICacheTransactionManager` interface(`Begin` / `Commit` / + `Rollback` / `Suspend` / `Resume`) +- [ ] `TXState` 內部狀態 class(cppcache `TXState`) +- [ ] `CacheTransactionManagerImpl` 實作 +- [ ] `IGeodeCache.CacheTransactionManager` 屬性入口 + +--- + +## 已知需要回填的 stat / metric + +從 Phase 1.5 推到這的可觀測性項目: + +- `subscriptionServers` IntGauge(`PoolStatistics` catalogue #2)— + HA primary/secondary 數量 +- `messagesBeingReceived` LongCounter(catalogue #21)— notification + 通道收到的 frame 數 +- `processedDeltaMessages` LongCounter(catalogue #22) +- `deltaMessageFailures` LongCounter(catalogue #23) +- `processedDeltaMessagesTime` LongCounter(catalogue #24) +- `queryExecutions` IntCounter(catalogue #25)— Phase 1.4 路徑已 + 存在,只是 stat 沒接 +- `queryExecutionTime` LongCounter(catalogue #26) + +--- + +## Locator follow-ons(從 Phase 1.5 推來) + +cppcache `ThinClientLocatorHelper` 上四個 public method,我們只實作 +了前兩個(`UpdateLocators` / `GetEndpointForNewFwdConn`);剩下兩個 +落 Phase 2 / Phase 4。 + +- **`getEndpointForNewCallBackConn`** — 訂閱通道專用的 endpoint + 選擇(避開主 op 通道)。Phase 2 Continuous Query 直接相依。 +- **`getAllServers`** — Phase 4 PR single-hop bucket-to-server + resolution 用。Phase 2 不會碰。 +- **`ClientReplacementRequest`** — Phase 4 failover swap(locator + 在某 endpoint 倒掉時挑替代 server)。Phase 2 不會碰。 + +--- + +## 待補的 PORTING.md class + +Phase 2 開工前先確認 [PORTING.md](PORTING.md) 已收錄以下 cppcache +class(目前部分未登記): + +- `PdxType` / `PdxTypeRegistry` / `PdxInstanceImpl` / `PdxFieldType` +- `IPdxSerializable` interface(cppcache `PdxSerializable`) +- `ThinClientRedundancyManager` +- `CqService` / `CqQueryImpl` / `CqListener` +- `RegisterInterestList` / `RegisterInterestListMessage` +- `TXState` / `CacheTransactionManagerImpl` +- `TcrChunkedResult` 變體(訂閱 chunk 處理) + +--- + +## 設計考量 + +- **Pool 模式 only** — 沿用 Phase 1 的決定,不重啟非 pool 路徑; + 訂閱 / HA 走 `ThinClientPoolHADM`,不會做 + `TcrHADistributionManager`(cppcache 的非 pool HA 變體)。 +- **TcrPoolEndPoint 階層遷移** — Phase 2 訂閱通道用到 + `TcrEndpoint.RegisterDMAsync(clientNotification: true)`,屆時 pool + 與 endpoint 的關係會被測試到。可能就是 pool-per-endpoint vs + cache-wide-shared 設計選擇的最後決定時機(見 PROGRESS1.md 的 + 「設計決策與已知偏離」)。 +- **PDX 對 .NET reflection 的依賴** — `PdxTypeRegistry` 需要從 + user-defined class 提取欄位 metadata;考慮 source generator + 方案(`[PdxSerializable]` attribute → compile-time emit)避開 + reflection 性能成本。 +- **訂閱事件的 async 形狀** — cppcache `CacheListener` 是 sync + callback;.NET 慣例會做 `IAsyncEnumerable` 或 + `Channel` 推送,讓 listener 可以 await。決策推到實作時。 diff --git a/README.md b/README.md index c2d48c1..f438729 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ var builder = Host.CreateApplicationBuilder(args); builder.Services.AddGeodeClient(builder.Configuration.GetSection("Geode")); var app = builder.Build(); -// 任何服務注入 IGeodeCache +// Inject IGeodeCache anywhere public class OrderService(IGeodeCache cache) { private readonly IRegion _orders = diff --git a/geode-dotnet.sln b/geode-dotnet.sln index b57b530..721df33 100644 --- a/geode-dotnet.sln +++ b/geode-dotnet.sln @@ -1,5 +1,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11806.211 +MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geode.Client", "src\Geode.Client\Geode.Client.csproj", "{11111111-1111-1111-1111-111111111111}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geode.Client.Tests", "tests\Geode.Client.Tests\Geode.Client.Tests.csproj", "{22222222-2222-2222-2222-222222222222}" @@ -10,15 +12,23 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Geode.Client.Sample", "samp EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution items", "solution items", "{55555555-5555-5555-5555-555555555555}" ProjectSection(SolutionItems) = preProject - Directory.Build.props = Directory.Build.props - Directory.Packages.props = Directory.Packages.props .editorconfig = .editorconfig .gitignore = .gitignore - CLAUDE.md = CLAUDE.md - README.md = README.md + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props docker-compose.yml = docker-compose.yml + PROGRESS.md = PROGRESS.md + PROGRESS1.md = PROGRESS1.md + PROGRESS2.md = PROGRESS2.md + README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{6FB7AB5D-1656-469E-B35C-566371134193}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{5CF6504F-03FC-4A37-A2A9-32A9547B6D5A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,4 +52,16 @@ Global {44444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU {44444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {11111111-1111-1111-1111-111111111111} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {22222222-2222-2222-2222-222222222222} = {6FB7AB5D-1656-469E-B35C-566371134193} + {33333333-3333-3333-3333-333333333333} = {6FB7AB5D-1656-469E-B35C-566371134193} + {44444444-4444-4444-4444-444444444444} = {5CF6504F-03FC-4A37-A2A9-32A9547B6D5A} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CFA3EA41-557B-4869-A3AD-F07FE3B3AC72} + EndGlobalSection EndGlobal diff --git a/samples/Geode.Client.Sample/Program.cs b/samples/Geode.Client.Sample/Program.cs index 1cbb68c..62ab482 100644 --- a/samples/Geode.Client.Sample/Program.cs +++ b/samples/Geode.Client.Sample/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/src/Geode.Client/AllConnectionsInUseException.cs b/src/Geode.Client/AllConnectionsInUseException.cs new file mode 100644 index 0000000..0e900d0 --- /dev/null +++ b/src/Geode.Client/AllConnectionsInUseException.cs @@ -0,0 +1,27 @@ +namespace Geode.Client; + +/// +/// Thrown when every connection in the pool is currently in use and +/// forbids +/// opening another one. Mirrors cppcache +/// AllConnectionsInUseException; corresponds to +/// GfErrType::GF_ALL_CONNECTIONS_IN_USE (cppcache pool's +/// maxConnLimit flag). +/// +/// +/// Phase 1.5 待辦: surfaces once CreatePoolConnectionAsync +/// enforces the MaxConnections cap. Treated as a transient +/// failure — the caller can wait on +/// for a +/// conn to return, or fail the op when the wait budget expires. +/// +public class AllConnectionsInUseException : GeodeException +{ + public AllConnectionsInUseException() { } + + public AllConnectionsInUseException(string message) + : base(message) { } + + public AllConnectionsInUseException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/AssemblyMarker.cs b/src/Geode.Client/AssemblyMarker.cs deleted file mode 100644 index e2a64d6..0000000 --- a/src/Geode.Client/AssemblyMarker.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Geode.Client; - -/// -/// Placeholder. The real public API surface (IGeodeCache, IRegion<TKey, TValue>, -/// AddGeodeClient extension) is delivered incrementally per the phase plan in -/// /CLAUDE.md. -/// -/// Phase 1 starts at with the frame codec. -/// -internal static class AssemblyMarker -{ -} diff --git a/src/Geode.Client/AuthenticationFailedException.cs b/src/Geode.Client/AuthenticationFailedException.cs new file mode 100644 index 0000000..7d9d22b --- /dev/null +++ b/src/Geode.Client/AuthenticationFailedException.cs @@ -0,0 +1,24 @@ +namespace Geode.Client; + +/// +/// Thrown when the Geode server rejects the client's credentials during +/// handshake or a privileged op. Mirrors cppcache +/// AuthenticationFailedException; corresponds to +/// GfErrType::GF_AUTHENTICATION_FAILED_EXCEPTION. +/// +/// +/// Treated as a fatal-client failure by the pool's failover retry loop: +/// every server in the cluster shares the same auth realm, so retrying +/// on a different host doesn't help. Phase 3 security wires this in; +/// MVP never throws it. +/// +public class AuthenticationFailedException : GeodeException +{ + public AuthenticationFailedException() { } + + public AuthenticationFailedException(string message) + : base(message) { } + + public AuthenticationFailedException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/AuthenticationRequiredException.cs b/src/Geode.Client/AuthenticationRequiredException.cs new file mode 100644 index 0000000..e3d0096 --- /dev/null +++ b/src/Geode.Client/AuthenticationRequiredException.cs @@ -0,0 +1,24 @@ +namespace Geode.Client; + +/// +/// Thrown when the Geode server requires authentication but the client +/// connected without credentials. Mirrors cppcache +/// AuthenticationRequiredException; corresponds to +/// GfErrType::GF_AUTHENTICATION_REQUIRED_EXCEPTION. +/// +/// +/// Treated as a fatal-client failure by the pool's failover retry loop: +/// the missing credentials apply to every server in the cluster, so +/// retrying on a different host doesn't help. Phase 3 security wires +/// this in; MVP never throws it. +/// +public class AuthenticationRequiredException : GeodeException +{ + public AuthenticationRequiredException() { } + + public AuthenticationRequiredException(string message) + : base(message) { } + + public AuthenticationRequiredException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/CacheServerException.cs b/src/Geode.Client/CacheServerException.cs new file mode 100644 index 0000000..e45764c --- /dev/null +++ b/src/Geode.Client/CacheServerException.cs @@ -0,0 +1,23 @@ +namespace Geode.Client; + +/// +/// Thrown when the Geode server returns a MessageType.Exception +/// reply to a client request (Put / Get / Query / etc.). Mirrors +/// cppcache CacheServerException; corresponds to +/// GfErrType::GF_CACHESERVER_EXCEPTION (server-side failure +/// surfaced in the reply rather than a transport issue). +/// +/// +/// The message body carries the server-supplied Java exception class +/// name + stack trace decoded from the wire payload. +/// +public class CacheServerException : GeodeException +{ + public CacheServerException() { } + + public CacheServerException(string message) + : base(message) { } + + public CacheServerException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/Geode.Client.csproj b/src/Geode.Client/Geode.Client.csproj index 2c81404..437e843 100644 --- a/src/Geode.Client/Geode.Client.csproj +++ b/src/Geode.Client/Geode.Client.csproj @@ -13,6 +13,10 @@ alpha.0 + + 1701;1702;CA1873 + + diff --git a/src/Geode.Client/GeodeClientExtensions.cs b/src/Geode.Client/GeodeClientExtensions.cs new file mode 100644 index 0000000..1066472 --- /dev/null +++ b/src/Geode.Client/GeodeClientExtensions.cs @@ -0,0 +1,183 @@ +//using Geode.Client.Internal; +//using Geode.Client.Options; +//using Geode.Client.Pdx; +//using Geode.Client.Protocol; +//using Geode.Client.Protocol.Serialization; +//using Microsoft.Extensions.Configuration; +using Geode.Client.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Geode.Client; + +/// +/// DI registration entry points for the Geode managed client. +/// +public static class GeodeClientExtensions +{ + ///// + ///// Default section name used by + ///// overloads that bind from the host : + ///// and + ///// with + ///// empty name. + ///// + //public const string DefaultSectionName = "Geode"; + + //// ── AddGeodeClient ─ register the unnamed default + IGeodeCache alias ── + + ///// Register the default config (bound from ) and the injection alias. + //public static IServiceCollection AddGeodeClient(this IServiceCollection services) + //{ + // ArgumentNullException.ThrowIfNull(services); + + // services.AddOptions("") + // .BindConfiguration(DefaultSectionName) + // .ValidateOnStart(); + // AddCore(services); + // RegisterUnnamedCacheAlias(services); + // return services; + //} + + ///// Register the default config (bound from ) and the injection alias. + //public static IServiceCollection AddGeodeClient( + // this IServiceCollection services, + // IConfiguration configuration) + //{ + // ArgumentNullException.ThrowIfNull(services); + // ArgumentNullException.ThrowIfNull(configuration); + + // services.AddOptions("") + // .Bind(configuration) + // .ValidateOnStart(); + // AddCore(services); + // RegisterUnnamedCacheAlias(services); + // return services; + //} + + ///// Register the default config (programmatic) and the injection alias. + //public static IServiceCollection AddGeodeClient( + // this IServiceCollection services, + // Action configure) + //{ + // ArgumentNullException.ThrowIfNull(services); + // ArgumentNullException.ThrowIfNull(configure); + + // services.AddOptions("") + // .Configure(configure) + // .ValidateOnStart(); + // AddCore(services); + // RegisterUnnamedCacheAlias(services); + // return services; + //} + + //// ── AddGeodeFactory ─ register a named config; no IGeodeCache alias ── + + ///// + ///// Register a named config bound from the host . + ///// Section name is when non-empty, + ///// otherwise . Retrieve the cache + ///// via . + ///// + //public static IServiceCollection AddGeodeFactory( + // this IServiceCollection services, + // string name = "") + //{ + // ArgumentNullException.ThrowIfNull(services); + // ArgumentNullException.ThrowIfNull(name); + + // var section = string.IsNullOrEmpty(name) ? DefaultSectionName : name; + // services.AddOptions(name) + // .BindConfiguration(section) + // .ValidateOnStart(); + // return AddCore(services); + //} + + ///// Register a named config bound from . Retrieve via . + //public static IServiceCollection AddGeodeFactory( + // this IServiceCollection services, + // IConfiguration configuration, + // string name = "") + //{ + // ArgumentNullException.ThrowIfNull(services); + // ArgumentNullException.ThrowIfNull(configuration); + // ArgumentNullException.ThrowIfNull(name); + + // services.AddOptions(name) + // .Bind(configuration) + // .ValidateOnStart(); + // return AddCore(services); + //} + + ///// Register a named config (programmatic). Retrieve via . + //public static IServiceCollection AddGeodeFactory( + // this IServiceCollection services, + // Action configure, + // string name = "") + //{ + // ArgumentNullException.ThrowIfNull(services); + // ArgumentNullException.ThrowIfNull(configure); + // ArgumentNullException.ThrowIfNull(name); + + // services.AddOptions(name) + // .Configure(configure) + // .ValidateOnStart(); + // return AddCore(services); + //} + + //// ── private helpers ─────────────────────────────────────────── + + ///// + ///// Shared registration body — singleton factory plus the per-cache + ///// Scoped services that every instance + ///// requires. Idempotent via TryAdd*: multiple + ///// / + ///// calls + ///// (with different names) share one factory and one set of service + ///// descriptors. + ///// + //private static IServiceCollection AddCore(IServiceCollection services) + //{ + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddSingleton(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + // services.TryAddScoped(); + + // services.TryAddEnumerable( + // ServiceDescriptor.Singleton, GeodeClientOptionsValidator>()); + + // services.TryAddSingleton(); + + // return services; + //} + + ///// + ///// Register the unnamed-default alias. + ///// Resolution goes through — + ///// throws if the consumer + ///// forgot to call at host + ///// startup. + ///// + //private static void RegisterUnnamedCacheAlias(IServiceCollection services) + //{ + // services.TryAddSingleton(static sp => + // sp.GetRequiredService().Get("")); + //} + + public static IServiceCollection AddGeodeFactory(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } +} diff --git a/src/Geode.Client/GeodeException.cs b/src/Geode.Client/GeodeException.cs new file mode 100644 index 0000000..7d54601 --- /dev/null +++ b/src/Geode.Client/GeodeException.cs @@ -0,0 +1,30 @@ +namespace Geode.Client; + +/// +/// Base exception for Geode-specific protocol-level failures: server-side +/// refusals (e.g. handshake rejection), malformed wire bytes, and exceptions +/// returned by the server in MessageType.Exception replies. +/// +/// +/// +/// Distinct from / +/// which surface for +/// genuine transport failures, and from +/// which is reserved for API +/// misuse (e.g. SendAsync before ConnectAsync). +/// +/// +/// Catch this type to handle "the Geode server said something we couldn't +/// proceed with" without swallowing unrelated BCL failures. +/// +/// +public class GeodeException : Exception +{ + public GeodeException() { } + + public GeodeException(string message) + : base(message) { } + + public GeodeException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/IGeodeCache.cs b/src/Geode.Client/IGeodeCache.cs new file mode 100644 index 0000000..39755c2 --- /dev/null +++ b/src/Geode.Client/IGeodeCache.cs @@ -0,0 +1,46 @@ +namespace Geode.Client; + +/// +/// A connection to a single Geode cluster, obtained from . +/// +public interface IGeodeCache : IRegionService +{ + + /// + /// Open a fluent builder for a client-side region attached to this cache, + /// pre-loaded with the defaults implied by . + /// + RegionFactory CreateRegionFactory(RegionShortcut shortcut); + + ///// PDX type registry for this cache. + //ITypeRegistry TypeRegistry { get; } + + /// + /// Returns the OQL query service for the given pool ( or empty selects PoolManager.DefaultPool). + /// + /// + /// is supplied but no pool with that name is registered. + /// + /// + /// No default pool exists (cache not initialised, or all pools destroyed). + /// + IQueryService GetQueryService(string? poolName = null); + + /// + /// Cache name. + /// + string Name { get; } + + /// + /// Pool manager scoped to this cache. + /// + IPoolManager PoolManager { get; } + + ///// Drop fields the local schema doesn't know about on read. + //bool PdxIgnoreUnreadFields { get; } + + ///// Keep PDX values serialised on read. + //bool PdxReadSerialized { get; } +} + + diff --git a/src/Geode.Client/IGeodeCacheFactory.cs b/src/Geode.Client/IGeodeCacheFactory.cs new file mode 100644 index 0000000..f3eb066 --- /dev/null +++ b/src/Geode.Client/IGeodeCacheFactory.cs @@ -0,0 +1,82 @@ +using System.Diagnostics.CodeAnalysis; +using Geode.Client.Options; + +namespace Geode.Client; + +/// +/// Builds, retrieves, and disposes named instances. +/// +public interface IGeodeCacheFactory : IAsyncDisposable +{ + + /// + /// Build, register, and initialise a new cache under + /// with built-in defaults (zero-config shortcut). The returned cache is fully + /// initialised (TCCM bootstrapped) and ready to use. + /// + /// Cache identifier; must be unique across the factory's lifetime. + /// Cooperative cancellation for the build + initialise pipeline. + /// + /// already exists. + /// + /// + /// Factory has been disposed. + /// + Task CreateAsync(string cacheName, CancellationToken ct = default); + + /// + /// Build, register, and initialise a new cache under , + /// running against a fresh + /// beforehand. + /// + /// Cache identifier; must be unique across the factory's lifetime. + /// + /// Optional callback that mutates a fresh + /// before the cache is built. The factory's + /// is forwarded so the callback can resolve IConfiguration, + /// IOptions<T>, or other DI services when computing values. + /// leaves the cache on built-in defaults (equivalent + /// to the 2-argument overload). + /// + /// Cooperative cancellation for the build + initialise pipeline. + /// + /// already exists. + /// + /// + /// Factory has been disposed. + /// + Task CreateAsync( + string cacheName, + Action? configure = null, + CancellationToken ct = default); + + /// + /// Close and remove a single cache; if no such cache. + /// + /// + /// Factory has been disposed. + /// + ValueTask DisposeCacheAsync(string cacheName); + + /// + /// Get a built cache by name. + /// + /// + /// No cache exists under . + /// + IGeodeCache Get(string cacheName); + + /// + /// Try to get a built cache by name; if not found. + /// + bool TryGet(string cacheName, [NotNullWhen(true)] out IGeodeCache? cache); + + /// + /// Snapshot of names whose caches have been built. + /// + /// + /// Factory has been disposed. + /// + IReadOnlyCollection CacheNames { get; } + +} diff --git a/src/Geode.Client/IPool.cs b/src/Geode.Client/IPool.cs new file mode 100644 index 0000000..ffda3d8 --- /dev/null +++ b/src/Geode.Client/IPool.cs @@ -0,0 +1,59 @@ + +namespace Geode.Client; + +/// +/// A named connection pool to a Geode cluster. Mirrors cppcache +/// Pool (cppcache/include/geode/Pool.hpp). +/// +/// +/// +/// Internal. No MVP consumer use case exposes this surface; +/// IGeodeCache + IRegion covers everything callers need. +/// Lift to public when monitoring / advanced lifecycle hooks +/// require it (internal → public is non-breaking; the reverse +/// is not). +/// +/// +/// Sole implementor is the cppcache equivalent ThinClientPoolDM +/// (multi-inherits ThinClientBaseDM + Pool + +/// ConnectionQueue); subclasses ThinClientPoolHADM / +/// ThinClientPoolStickyDM add HA / sticky-tx behaviour. +/// +/// +public interface IPool : IAsyncDisposable +{ + ///// + ///// Tear the pool down. Mirrors cppcache Pool::destroy(keepAlive). + ///// + ///// + ///// When true, leaves subscription queues alive on the server + ///// for durable clients (cppcache semantics). Until durable + ///// subscriptions ship (Phase 2+) implementations may treat this as + ///// a no-op equivalent to false. + ///// + ///// Cooperative cancellation. + ///// + ///// DisposeAsync on is + ///// expected to delegate to DestroyAsync(keepAlive: false) + ///// so using blocks Just Work. + ///// + //Task DestroyAsync(bool keepAlive = false, CancellationToken ct = default); + + /// + /// Pool-scoped OQL query factory. + /// + IQueryService QueryService { get; } + + //// TODO Phase 1.5: + //// string Name { get; } + //// bool IsDestroyed { get; } + //// PoolOptions Options { get; } // replaces 30+ cppcache getters + //// IReadOnlyList Locators { get; } + //// IReadOnlyList Servers { get; } + //// + //// Skipped (cppcache surface we will not expose): + //// releaseThreadLocalConnection() — bucket 1, AsyncLocal + //// createAuthenticatedView() — Phase 3 + //// getPendingEventCount() — bucket 1, Meter counter +} + diff --git a/src/Geode.Client/IPoolManager.cs b/src/Geode.Client/IPoolManager.cs new file mode 100644 index 0000000..62adc9a --- /dev/null +++ b/src/Geode.Client/IPoolManager.cs @@ -0,0 +1,25 @@ +namespace Geode.Client; + +/// +/// Registry and lifecycle owner for named connection pools. +/// +public interface IPoolManager : IAsyncDisposable +{ + /// The first pool registered with this manager, or if none. + IPool? DefaultPool { get; } + + /// New bound to this manager. + PoolFactory CreateFactory(); + + /// Close every registered pool; idempotent. + Task CloseAsync(bool keepAlive = false, CancellationToken ct = default); + + /// Look up a pool by name; when missing. name returns . + IPool? Find(string? name = null); + + /// Look up the pool a region was created on; when missing. + IPool? Find(IRegion region); + + /// Snapshot of the registry; free to mutate without affecting the manager. + IReadOnlyDictionary GetAll(); +} diff --git a/src/Geode.Client/IQuery.cs b/src/Geode.Client/IQuery.cs new file mode 100644 index 0000000..ec9e008 --- /dev/null +++ b/src/Geode.Client/IQuery.cs @@ -0,0 +1,29 @@ +namespace Geode.Client; + +/// +/// A reusable handle for an OQL query. +/// +/// Row type the result is decoded as. +public interface IQuery +{ + /// The OQL string this query was created with. + string QueryString { get; } + + /// + /// Server-side response timeout. + /// + TimeSpan ResponseTimeout { get; set; } + + /// + /// Positional bind values for OQL placeholders $1, + /// $2, ... + /// + IList Parameters { get; } + + /// Execute the OQL on the server and return all rows. + /// Server-side query / parse error. + /// + /// The owning query service has been closed. + /// + Task> ExecuteAsync(CancellationToken ct = default); +} diff --git a/src/Geode.Client/IQueryService.cs b/src/Geode.Client/IQueryService.cs new file mode 100644 index 0000000..01157d4 --- /dev/null +++ b/src/Geode.Client/IQueryService.cs @@ -0,0 +1,21 @@ +namespace Geode.Client; + +/// +/// Factory for OQL queries. Obtained from +/// . +/// +public interface IQueryService +{ + /// + /// Build an for . + /// Does not send anything to the server until + /// is + /// called. + /// + /// Expected row type. + /// The OQL string. + /// + /// is , empty, or whitespace. + /// + IQuery NewQuery(string oql); +} diff --git a/src/Geode.Client/IRegion.cs b/src/Geode.Client/IRegion.cs new file mode 100644 index 0000000..747a65c --- /dev/null +++ b/src/Geode.Client/IRegion.cs @@ -0,0 +1,105 @@ + +namespace Geode.Client; + +/// +/// Non-generic region surface; key / value typed as . +/// +public interface IRegion +{ + /// Region's local name (last segment of ). + string Name { get; } + + /// Name of the + /// this region was created on; empty when the cache's default pool is used. + /// + string PoolName { get; } + + /// Full path including parent regions (e.g. "/orders"). + string FullPath { get; } + + /// Put under on the server. + Task PutAsync(object key, object value, CancellationToken ct = default); + + /// Get the value under ; when the key is absent. + Task GetAsync(object key, CancellationToken ct = default); + + /// Remove ; returns when the key existed. + Task RemoveAsync(object key, CancellationToken ct = default); + + /// Check whether exists on the server. + Task ContainsKeyAsync(object key, CancellationToken ct = default); + + /// Clear every entry from the region on the server (region itself stays). + Task ClearAsync(CancellationToken ct = default); + + /// Invalidate + /// on the server — the key stays, the value becomes . + /// + Task InvalidateAsync(object key, CancellationToken ct = default); + + /// Remove every key in from the region in one server roundtrip. + Task RemoveAllAsync(IReadOnlyCollection keys, CancellationToken ct = default); + + /// Put every entry in on the server in one roundtrip. + Task PutAllAsync(IReadOnlyDictionary map, CancellationToken ct = default); + + /// + /// Fetch every key in from the server in one roundtrip; + /// server-missing keys appear with . + /// + Task> GetAllAsync( + IReadOnlyCollection keys, CancellationToken ct = default); + + /// + /// Returns when at least one entry in the region satisfies + /// the OQL (WHERE-clause only). + /// + Task ExistsValueAsync(string predicate, CancellationToken ct = default); + + /// + /// Single-result OQL lookup: when no match, the value when exactly one match, + /// throws when more than one. + /// + Task SelectValueAsync(string predicate, CancellationToken ct = default); +} + +/// +/// Strongly-typed overlay on ; +/// must implement , type mismatches surface as +/// . +/// +public interface IRegion : IRegion + where TKey : IEquatable +{ + /// + Task PutAsync(TKey key, TValue value, CancellationToken ct = default); + + /// + Task GetAsync(TKey key, CancellationToken ct = default); + + /// + Task RemoveAsync(TKey key, CancellationToken ct = default); + + /// + Task ContainsKeyAsync(TKey key, CancellationToken ct = default); + + /// + Task InvalidateAsync(TKey key, CancellationToken ct = default); + + /// + Task RemoveAllAsync(IReadOnlyCollection keys, CancellationToken ct = default); + + /// + Task PutAllAsync(IReadOnlyDictionary map, CancellationToken ct = default); + + /// + /// Fetch every key in from the server in one roundtrip; + /// server-missing keys are absent from the result + /// (not present with ). + /// + Task> GetAllAsync( + IReadOnlyCollection keys, CancellationToken ct = default); + + /// + new Task SelectValueAsync(string predicate, CancellationToken ct = default); +} diff --git a/src/Geode.Client/IRegionService.cs b/src/Geode.Client/IRegionService.cs new file mode 100644 index 0000000..b00ca08 --- /dev/null +++ b/src/Geode.Client/IRegionService.cs @@ -0,0 +1,35 @@ +namespace Geode.Client; + +/// +/// Common region / query lookup contract; implemented by . +/// +public interface IRegionService : IAsyncDisposable +{ + /// Whether has been called. + bool IsClosed { get; } + + /// Gracefully close the underlying connection(s); subsequent calls are a no-op. + Task CloseAsync(CancellationToken ct = default); + + /// + /// Get the strongly-typed handle for the region at ; + /// when no region is registered there. + /// + /// + /// is empty or just "/". + /// + /// + /// The region exists but is already attached under different type parameters. + /// + IRegion? GetRegion(string path) + where TKey : IEquatable; + + /// + /// Untyped lookup overload of ; + /// when no region with is registered. + /// + IRegion? GetRegion(string path); + + // Phase 1.x: IReadOnlyList RootRegions { get; } + // Phase 2: PdxInstanceFactory CreatePdxInstanceFactory(string className, ...); +} diff --git a/src/Geode.Client/Internal/ChunkedGetAllResponse.cs b/src/Geode.Client/Internal/ChunkedGetAllResponse.cs new file mode 100644 index 0000000..5bb6eb0 --- /dev/null +++ b/src/Geode.Client/Internal/ChunkedGetAllResponse.cs @@ -0,0 +1,276 @@ +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// consumer for the chunked reply of a +/// request. Mirrors cppcache +/// ChunkedGetAllResponse +/// (cppcache/src/ThinClientRegion.hpp:487-549 + +/// cppcache/src/ThinClientRegion.cpp:3616-3670). +/// +/// +/// +/// Phase 1.3.c status: empty skeleton. +/// / throw +/// ; +/// accessor returns the empty accumulator until those bodies land. +/// +/// +/// Differs from / +/// : GetAll's reply ships +/// real values (server's VersionedCacheableObjectPartList sets +/// hasObjects=true), so this handler exercises +/// 's objects-section +/// decode path (step 5) — the path 1.3.b wrote but never hit. +/// The accumulating dictionary is the actual +/// op return value, not a side-effect (PutAll / RemoveAll drop their +/// version-tag accumulators on the floor). +/// +/// +/// Per-chunk wire shape (cppcache handleChunk +/// classifies via ): +/// +/// +/// OBJECT ( + +/// ) — one +/// instance +/// with hasObjects=true; decode N values back into the +/// shared dict indexed by +/// Keys[index + keysOffset], then advance +/// by the consumed entry count. +/// EXCEPTION — per-chunk exception part; +/// flips +/// the classifier and our caller's reply switch will throw. +/// Unlike PutAll / RemoveAll, GetAll has no NULL_OBJECT or +/// BYTES branch — only Object or Exception +/// (cppcache ThinClientRegion.cpp:3630-3637). +/// +/// +/// Result accumulators mirror cppcache's +/// ChunkedGetAllResponse 8-arg ctor +/// (cppcache/src/ThinClientRegion.cpp:1122-1124): +/// m_keys (caller-supplied, positional index source) / +/// m_values / m_exceptions / m_resultKeys / +/// m_keysOffset are accumulated across all chunks, then read +/// back by the caller after the dispatcher returns. Phase 1.3 +/// exposes only — per-key exceptions / +/// partial-result keys lands when the public surface grows a partial +/// API. +/// +/// +/// DI scope for reader / VCOPL construction. +/// Severity-aligned with cppcache LOG* calls. +/// Shared chunk-part-header decoder. +/// Region this op runs against. Mirrors cppcache +/// ChunkedGetAllResponse::m_region. +/// Original keys we sent — the chunked +/// reply's per-entry slot i maps back to keys[i + keysOffset]. +/// Mirrors cppcache ChunkedGetAllResponse::m_keys +/// (const std::vector<CacheableKey>*); we hold an +/// for positional access. +/// Whether +/// should run its step-7 putLocal merge (write decoded values +/// into the region's client-side cache). Mirrors cppcache +/// ChunkedGetAllResponse::m_addToLocalCache; the caller +/// () ANDs the requested +/// flag with the region's caching-enabled attribute the same +/// way cppcache's getAllNoThrow_remote does +/// (ThinClientRegion.cpp:1100), so a proxy-mode region +/// (caching-enabled=false) sees this collapse to false +/// regardless of caller intent. +/// Reply for auth-trailer / +/// pool back-refs. Phase 3+ (auth) / Phase 4+ (single-hop) actually +/// read it; Phase 1.3 leaves null. +internal sealed class ChunkedGetAllResponse( + IServiceProvider serviceProvider, + ILogger logger, + TcrMessageHelper tcrMessageHelper, + ThinClientRegion region, + IReadOnlyList keys, + bool addToLocalCache, + TcrMessage? msg = null) : TcrChunkedResult +{ + /// + /// Per-key value accumulator filled by + /// across all chunks. Mirrors cppcache + /// ChunkedGetAllResponse::m_values + /// (std::shared_ptr<HashMapOfCacheable>); element value + /// nullable because GetAll's per-entry miss flag 3 stores + /// null for keys the server doesn't have. Caller + /// () reads this after + /// dispatch returns; exposed as + /// so the public surface can't mutate it post-return. + /// + private readonly Dictionary _values = []; + + /// + /// Per-key exception accumulator. Lazy — only allocated + /// when + /// hits the objType==2 branch. Phase 1.3 doesn't surface + /// per-key exceptions on the public API; the field stays for + /// cppcache parity and to drain the wire correctly. + /// + private readonly Dictionary? _exceptions = null; + + /// + /// Subset of keys the server actually + /// returned data for. Mirrors cppcache + /// ChunkedGetAllResponse::m_resultKeys; used by single-hop + /// (Phase 4+) to skip refreshing metadata for keys the server + /// already served. Phase 1.3 leaves null. + /// + private readonly List? _resultKeys = null; + + /// + /// Running cursor into . Advances by + /// + /// after each chunk's FromData finishes. Mirrors cppcache + /// ChunkedGetAllResponse::m_keysOffset + /// (uint32_t); the pointer-vs-value distinction (cppcache + /// uses &m_keysOffset so VCOPL can read+write the same + /// cursor) collapses to an explicit post-FromData read-back on + /// the .NET side — see . + /// + // Explicit init (0) silences CS0649 while HandleChunk's body is + // still NIE; the real assignment site is in HandleChunk's + // VCOPL-decode path (lands next). + private int _keysOffset = 0; + + /// + /// Per-key result. Keys are the same instances + /// the caller passed to + /// (we never re-decode them off the wire — the server doesn't + /// echo keys back for GetAll, only values indexed positionally). + /// Missing-on-server keys appear here with value null + /// (cppcache m_byteArray[i]==3 stores null). Read by + /// after dispatch + /// returns — the result is then returned through the + /// public API as IReadOnlyDictionary<object, object?>. + /// + public IReadOnlyDictionary Values => _values; + + public override void HandleChunk(ReadOnlyMemory payload, bool isLastChunk) + { + // Mirrors cppcache ChunkedGetAllResponse::handleChunk + // (cppcache/src/ThinClientRegion.cpp:3623-3648). Shorter than + // ChunkedPutAllResponse / ChunkedRemoveAllResponse because + // GetAll's reply is strictly Object-or-Exception — no + // NULL_OBJECT (server always ships values for a non-empty key + // list) and no BYTES branch (cppcache doesn't enqueue PR + // single-hop metadata refresh off GetAll replies). + + // ─── Step 1: wrap chunk bytes ────────────────────────── + // cppcache: cacheImpl->createDataInput(chunk, chunkLen, pool). + var reader = ActivatorUtilities.CreateInstance(serviceProvider, payload); + + // ─── Step 2: read chunk part header ──────────────────── + // Peels partLen + isObj + DSCode/FixedID combo. Expected + // leading DSCode = FixedIDByte (1-byte fixed-id follows); + // expected partType = DSFid.VersionedObjectPartList. If the + // classifier returns anything other than Object — typically + // Exception — we drain + bail; the reply.MessageType flip + // (handled inside ReadChunkPartHeader) routes the caller into + // ThinClientRegion.GetAllAsync's EXCEPTION switch. + var chunkType = tcrMessageHelper.ReadChunkPartHeader( + reader, + DSCode.FixedIDByte, + (int)DSFid.VersionedObjectPartList, + nameof(ChunkedGetAllResponse), + out var partLen, + isLastChunk: (byte)(isLastChunk ? 1 : 0)); + + if (chunkType != TcrMessageHelper.ChunkObjectType.Object) + { + // cppcache: "encountered an exception part, so return + // without reading more" (ThinClientRegion.cpp:3634-3637). + // TODO Phase 3+ — m_msg.readSecureObjectPart(reader, false, + // true, isLastChunkWithSecurity). Phase 1.3 no auth → + // security bit always 0 → no trailer bytes to consume. + logger.LogDebug( + "ChunkedGetAllResponse::handleChunk non-OBJECT chunk (chunkType={ChunkType}) — bailing", + chunkType); + return; + } + + // ─── Step 3: decode one VersionedCacheableObjectPartList ── + // cppcache constructs VCOPL with the 11-arg ctor passing the + // shared accumulators by pointer/ref so each chunk's fromData + // writes into the same dicts. Our Initialize method is the + // equivalent — keys are the caller-supplied list, keysOffset + // is the running cursor, values / exceptions / resultKeys are + // shared accumulators. addToLocalCache stays false for Phase + // 1.3 (no client-side caching), which gates VCOPL.FromData's + // step 7 (putLocal merge) into a no-op. + var vcObjPart = ActivatorUtilities.CreateInstance( + serviceProvider, region.SerializationRegistry, region); + vcObjPart.Initialize( + keys: keys, + keysOffset: _keysOffset, + values: GetMutableValues(), + exceptions: _exceptions, + resultKeys: _resultKeys, + addToLocalCache: addToLocalCache); + vcObjPart.FromData(reader); + + // ─── Step 4: advance shared cursor ───────────────────── + // cppcache passes &m_keysOffset so the per-chunk VCOPL writes + // through; .NET doesn't have pointer-to-int semantics here, so + // VCOPL exposes ConsumedObjectCount for the post-decode + // read-back. The next chunk's VCOPL.Initialize will pick up + // from the new cursor. + _keysOffset += vcObjPart.ConsumedObjectCount; + + // TODO Phase 3+ — m_msg.readSecureObjectPart(reader, false, + // true, isLastChunkWithSecurity). + _ = partLen; + _ = msg; + } + + /// + /// typed as the mutable + /// for the + /// call. + /// The public accessor narrows the surface to + /// so the caller + /// can't mutate post-return; VCOPL.Initialize needs the mutable + /// type to fill in entries during chunk decode. + /// + private Dictionary GetMutableValues() => _values; + + public override void Reset() + { + // Mirrors cppcache ChunkedGetAllResponse::reset + // (cppcache/src/ThinClientRegion.cpp:3616-3621): + // void ChunkedGetAllResponse::reset() { + // m_keysOffset = 0; + // if (m_resultKeys != nullptr && m_resultKeys->size() > 0) { + // m_resultKeys->clear(); + // } + // } + + // ─── Step 1: rewind cursor ──────────────────────────── + // Retry replays the chunks from scratch; the per-chunk decode + // path advances _keysOffset by ConsumedObjectCount, so it has + // to start at 0 again. + _keysOffset = 0; + + // ─── Step 2: drop result-keys subset ────────────────── + // cppcache null-guards via shared_ptr empty check; .NET via + // ?.Count + ?.Clear. Phase 1.3 leaves _resultKeys null so + // the conditional short-circuits — the field exists for + // cppcache parity and Phase 4+ single-hop will start + // populating it. + if (_resultKeys is { Count: > 0 }) + { + _resultKeys.Clear(); + } + + // Does NOT clear _values / _exceptions — cppcache leaves those + // alone too; the retry's chunks overwrite slot-by-slot using + // the same keys list, so stale entries from the failed attempt + // get replaced rather than removed. + } +} diff --git a/src/Geode.Client/Internal/ChunkedPutAllResponse.cs b/src/Geode.Client/Internal/ChunkedPutAllResponse.cs new file mode 100644 index 0000000..cc7c43e --- /dev/null +++ b/src/Geode.Client/Internal/ChunkedPutAllResponse.cs @@ -0,0 +1,193 @@ +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// consumer for the chunked reply of a +/// request. Mirrors cppcache +/// ChunkedPutAllResponse +/// (cppcache/src/ThinClientRegion.hpp:554-583 + +/// cppcache/src/ThinClientRegion.cpp:3672-3727). +/// +/// +/// +/// Wire shape mirrors 1:1 +/// — both PutAll and RemoveAll replies ship only per-key +/// version tags (no keys echoed, no values echoed), so the handler +/// is structurally a copy with only the diagnostic log strings +/// swapped. Phase 1.3 caller leaves the list accumulator +/// null — per-key version tags are discarded; the +/// handler still drains the chunk bytes so the reader stays aligned. +/// +/// +/// Expected payload per chunk (same three shapes as +/// , classified via +/// ): +/// +/// +/// NULL_OBJECT — server has no version info to +/// ship (empty batch / caching disabled). Consume the +/// secure-object trailer and return. +/// OBJECT ( + +/// ) — one +/// instance with +/// _hasTags=true; merge into the accumulating list via +/// AddAll. +/// BYTES — single-hop metadata refresh +/// (2 bytes [metadataVersion][networkHopType]). Drain +/// + ignore. Phase 4 (single-hop) actually consumes it. +/// +/// +/// Phase 1.3.c result. The public PutAllAsync contract +/// is plain — per-key version tags are +/// discarded. The accumulator is still built so the dispatcher +/// consumes the chunk bytes correctly. +/// +/// +/// DI scope for reader / VCOPL construction. +/// Severity-aligned with cppcache LOG* calls. +/// Shared chunk-part-header decoder. +/// Region this op runs against. Mirrors cppcache +/// ChunkedPutAllResponse::m_region. +/// Reply for auth-trailer / +/// pool back-refs. Nullable for the same reasons as +/// — Phase 3+ (auth) / +/// Phase 4+ (single-hop) actually read it. +/// Accumulating versioned-object-part list. Mirrors +/// cppcache ChunkedPutAllResponse::m_list; Phase 1.3 caller +/// leaves null — per-key results are dropped on the +/// floor anyway. +internal sealed class ChunkedPutAllResponse( + IServiceProvider serviceProvider, + ILogger logger, + TcrMessageHelper tcrMessageHelper, + ThinClientRegion region, + TcrMessage? msg = null, + VersionedCacheableObjectPartList? list = null) : TcrChunkedResult +{ + public override void HandleChunk(ReadOnlyMemory payload, bool isLastChunk) + { + // Mirrors cppcache ChunkedPutAllResponse::handleChunk + // (cppcache/src/ThinClientRegion.cpp:3678-3727). Body shape + // identical to ChunkedRemoveAllResponse.HandleChunk — same + // three chunk classifications, same per-chunk + // VersionedCacheableObjectPartList decode + AddAll merge. The + // only diff is the log-message strings and the cppcache "PUTALL + // operation" wording in the single-hop bytes branch. + + // ─── Step 1: wrap chunk bytes ────────────────────────── + var reader = ActivatorUtilities.CreateInstance(serviceProvider, payload); + + // ─── Step 2: read chunk part header ──────────────────── + // Peels partLen + isObj + DSCode/FixedID combo, classifies + // the chunk into NullObject / Object / Exception / Bytes. + // Expected leading DSCode = FixedIDByte (1-byte fixed-id + // follows); expected partType = DSFid.VersionedObjectPartList. + var chunkType = tcrMessageHelper.ReadChunkPartHeader( + reader, + DSCode.FixedIDByte, + (int)DSFid.VersionedObjectPartList, + nameof(ChunkedPutAllResponse), + out var partLen, + isLastChunk: (byte)(isLastChunk ? 1 : 0)); + + // ─── Step 3a: NULL_OBJECT branch ─────────────────────── + // Server has no version info to ship (empty batch / caching + // disabled). cppcache LOGDEBUG mirrored. + if (chunkType == TcrMessageHelper.ChunkObjectType.NullObject) + { + logger.LogDebug("ChunkedPutAllResponse::handleChunk nullptr object"); + // TODO Phase 3+ — m_msg.readSecureObjectPart(reader, false, + // true, isLastChunkWithSecurity). Phase 1.3 no auth → + // security bit always 0 → no trailer bytes to consume. + return; + } + + // ─── Step 3b: OBJECT branch ─────────────────────────── + // cppcache constructs a fresh VersionedCacheableObjectPartList + // per chunk, decodes via fromData, then merges into the + // accumulating m_list via addAll. Phase 1.3 caller doesn't + // supply `list`, so the merge is a no-op — accumulated per-key + // results are dropped on the floor anyway (PutAllAsync returns + // plain Task). + if (chunkType == TcrMessageHelper.ChunkObjectType.Object) + { + logger.LogDebug("ChunkedPutAllResponse::handleChunk object"); + + // cppcache: new VersionedCacheableObjectPartList(region, dsmemId, responseLock). + // Phase 1.3 — endpointMemId always 0 (no single-hop); + // responseLock not threaded through (single-task chunk drain). + var vcObjPart = ActivatorUtilities.CreateInstance( + serviceProvider, region.SerializationRegistry, region); + vcObjPart.FromData(reader); + + list?.AddAll(vcObjPart); + + // TODO Phase 3+ — m_msg.readSecureObjectPart(reader, false, + // true, isLastChunkWithSecurity). + return; + } + + // ─── Step 3c: BYTES branch ──────────────────────────── + // Single-hop PR metadata refresh prelude: 2 raw bytes + // [metadataVersion][networkHopType]. Drain them so the wire + // reader stays aligned; the enqueue-for-refresh call + // (cppcache ThinClientRegion.cpp:3713-3725) is Phase 4+ work + // (needs ClientMetaDataService + ThinClientPoolDM.GetPool()). + if (chunkType == TcrMessageHelper.ChunkObjectType.Bytes) + { + logger.LogDebug("ChunkedPutAllResponse::handleChunk BYTES PART"); + var metadataVersion = reader.ReadByte(); + logger.LogDebug( + "ChunkedPutAllResponse::handleChunk single-hop bytes byte0 = {Byte0}", + metadataVersion); + var networkHopType = reader.ReadByte(); + + // TODO Phase 3+ — m_msg.readSecureObjectPart(...). + // TODO Phase 4+ — when metadataVersion != 0 and pool has + // PRSingleHopEnabled + ClientMetaDataService, enqueue: + // poolDM.ClientMetaDataService.EnqueueForMetadataRefresh( + // region.FullPath, networkHopType); + // cppcache LOGFINE wording: + // "enqueued region for metadata refresh for + // singlehop for PUTALL operation." + _ = metadataVersion; + _ = networkHopType; + return; + } + + // Fallthrough: ChunkObjectType.Exception (or unforeseen value). + // cppcache flips reply.MessageType to EXCEPTION inside + // readChunkPartHeader and lets the caller's reply switch handle + // it; our TcrMessage record is immutable so we can't propagate + // that way — throw and let the chunked reader unwind to + // ThinClientRegion.PutAllAsync's EXCEPTION switch. + _ = partLen; + _ = msg; + throw new GeodeException( + $"ChunkedPutAllResponse.HandleChunk: unhandled chunkType={chunkType}."); + } + + public override void Reset() + { + // Mirrors cppcache ChunkedPutAllResponse::reset + // (cppcache/src/ThinClientRegion.cpp:3672-3676). Identical + // 2-step body as ChunkedRemoveAllResponse.Reset — both bulk + // ops ship only version tags, so the retry-clear path is the + // same. + + // ─── Step 1: null + size guard ─────────────────────── + if (list is null || list.Size <= 0) + { + return; + } + + // ─── Step 2: clear inner versionTags vector ONLY ───── + // Does NOT null the _list reference, does NOT clear other + // fields — cppcache keeps the same _list instance so retries + // reuse the accumulator. + list.VersionTags.Clear(); + } +} diff --git a/src/Geode.Client/Internal/ChunkedQueryResponse.cs b/src/Geode.Client/Internal/ChunkedQueryResponse.cs new file mode 100644 index 0000000..ca7e751 --- /dev/null +++ b/src/Geode.Client/Internal/ChunkedQueryResponse.cs @@ -0,0 +1,554 @@ +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// consumer for the chunked reply of a +/// / +/// request. Mirrors cppcache ChunkedQueryResponse +/// (cppcache/src/ThinClientRegion.hpp:411-444; impl in +/// cppcache/src/ThinClientRegion.cpp:3291-3480). +/// +/// +/// +/// Phase 1.4 status: step-list skeleton. +/// / / +/// / bodies +/// are C1–C12 / R1–R3 / S1–S4 / K1–K2 step comments + NIE; impl lands +/// step by step. +/// +/// +/// Generic : the row type the caller +/// expects (driven by ). For SELECT * +/// this is the region's value type; for SELECT COUNT(*) it is +/// typically ; multi-column projection +/// (SELECT field1, field2) uses Struct. Whether +/// conversion happens inside this decoder +/// (push directly typed ) or in +/// B10 (push +/// raw ?, project at the consumer) is deferred to +/// impl time — both are viable given the cppcache "flat +/// CacheableVector" intermediate. +/// +/// +/// Phase 1.4 vs cppcache scope. +/// +/// +/// m_queryResults (cppcache CacheableVector) +/// → . +/// m_structFieldNames → +/// ; populated by C7 only when +/// the reply's collection type is +/// org.apache.geode.cache.query.Struct. +/// readSecureObjectPart (auth trailer) — Phase 3 scope; +/// C12 leaves it as a no-op while +/// is null in Phase 1.4. +/// +/// +/// DI scope for per-chunk +/// construction. +/// Severity-aligned with cppcache LOG* calls. +/// Shared chunk-part-header decoder +/// (cppcache readChunkPartHeader). +/// Reply for auth-trailer +/// / pool back-refs. Mirrors cppcache ChunkedQueryResponse::m_msg; +/// Phase 3+ (auth) actually reads it, Phase 1.4 leaves null. +internal sealed class ChunkedQueryResponse( +#pragma warning disable CS9113 // unused while bodies are step-list skeletons + IServiceProvider serviceProvider, + ILogger> logger, + TcrMessageHelper tcrMessageHelper, + SerializationRegistry serializationRegistry, + TcrMessage? msg = null) : TcrChunkedResult +#pragma warning restore CS9113 +{ + /// + /// Typed row accumulator filled by + /// across all chunks. Mirrors cppcache + /// ChunkedQueryResponse::m_queryResults. For + /// single-column queries each element is one row value cast to + /// ; for multi-column projection + /// ( = ) the + /// decoder groups K field values per row and pushes one + /// assembled per row. Either way the + /// caller-facing shape is IReadOnlyList<T?>; + /// B10 + /// returns this directly. + /// + private readonly List _results = []; + + /// + /// Struct projection field names. Mirrors cppcache + /// ChunkedQueryResponse::m_structFieldNames. Empty when the + /// reply is a single-column ResultSet; populated by C7 when + /// the reply's collection type is + /// org.apache.geode.cache.query.Struct. + /// + private readonly List _structFieldNames = []; + + public IReadOnlyList Results => _results; + public IReadOnlyList StructFieldNames => _structFieldNames; + + // ──────────────────────────────────────────────────────────── + // Step list for HandleChunk / ReadObjectPartList / SkipClass / + // Reset. Mirrors cppcache ChunkedQueryResponse::handleChunk + + // readObjectPartList + skipClass + reset + // (ThinClientRegion.cpp:3291-3480). + // + // ── Phase 1.4 skipped ── + // • readSecureObjectPart (auth trailer) — Phase 3 + // • cacheImpl / pool back-refs — replaced by DI-injected + // SerializationRegistry / BigEndianBinaryReader + // + // ── Open design questions (decide at impl time) ── + // • T conversion site: + // (a) push object? here, RemoteQuery.B10 projects + // (b) push T inline (each readObject cast to T) + // • Struct reshape site: + // (i) collector returns flat values; B10 reshapes + // (ii) collector reshapes; B10 returns Results directly + // + public override void HandleChunk(ReadOnlyMemory payload, bool isLastChunk) + { + // C1 — Log entry. cppcache L3350. + logger.LogDebug("ChunkedQueryResponse::handleChunk.."); + + // C2 — Wrap chunk bytes in BigEndianBinaryReader. cppcache + // L3351: createDataInput(chunk, chunkLen, pool). Matches + // ChunkedGetAllResponse pattern (DI-built reader so future + // per-reader deps flow in without ctor churn). + var reader = ActivatorUtilities.CreateInstance( + serviceProvider, payload); + + // C3 — Read chunk part header. cppcache L3354-3357. Classifier: + // • C3a Exception → caller's B7 reply switch throws. + // • C3b NullObject → scalar COUNT(*) result follows in a + // fresh part after the null header. + // • C3c Object → continue to C4. + var chunkType = tcrMessageHelper.ReadChunkPartHeader( + reader, + DSCode.FixedIDByte, + (int)DSFid.CollectionTypeImpl, + nameof(ChunkedQueryResponse<>), + out var partLen, + isLastChunk: (byte)(isLastChunk ? 1 : 0)); + + if (chunkType == TcrMessageHelper.ChunkObjectType.Exception) + { + // C3a — cppcache L3358-3361 reads readSecureObjectPart; + // Phase 1.4 has no msg → no auth trailer to drain. The + // chunk-type flip is enough — the dispatcher records the + // EXCEPTION reply MessageType and RemoteQuery.B7 throws. + return; + } + + if (chunkType == TcrMessageHelper.ChunkObjectType.NullObject) + { + // C3b — scalar result (SELECT COUNT(*)). cppcache L3362-3370: + // the chunked reply ships a fresh part after the null + // header carrying the actual Int32 value. + reader.ReadInt32(); // next-part partLen, ignored + reader.ReadBool(); // next-part isObj, ignored + var scalar = serializationRegistry.ReadObject(reader); + _results.Add((T?)scalar); + // TODO Phase 3 — m_msg.readSecureObjectPart(reader, ...). + return; + } + + // C3c — Object chunk; delegate to the body decoder (C4–C12). + HandleObjectChunk(reader, partLen); + } + + /// + /// Decode the Object-branch body of a query chunk (C4–C12). Split + /// out of so the Exception / NullObject + /// short-circuits stay readable. cppcache keeps everything inline + /// in ChunkedQueryResponse::handleChunk + /// (ThinClientRegion.cpp:3380-3465); we factor by branch + /// type for clarity. + /// + /// Reader positioned right after C3's + /// readChunkPartHeader consumed the partLen + isObj + + /// FixedIDByte + DSFid.CollectionTypeImpl prefix. + /// First part's payload length (cppcache + /// partLen). Used by C8 to advance past the metadata part + /// once C7 has extracted any Struct field names. + private void HandleObjectChunk(DataInput reader, int partLen) + { + // C4 — Skip the outer collection type's parent-class header + // (cppcache L3380's skipClass). server tags the wrapper as + // HashSet / StructSet here; Phase 1.4 doesn't need to + // distinguish — the inner class name (C6) carries the real + // ResultSet vs StructSet discriminator. cppcache: + // // ignoring parent classes for now + // // we will require to look at it once CQ is to be implemented. + // skipClass(input); + SkipClass(reader); + + // C5 — Consume the fixed 3-byte preamble of the inner class + // header. cppcache L3384-3386 reads and discards each: + // input.read(); // FixedIDByte (1) + // input.read(); // DataSerializable (45) + // input.read(); // Class (43) + // No assertion in cppcache; a mismatch surfaces as garbage + // bytes in C6's readString below. Same trust-the-server + // posture here — defensive validation can land if + // integration tests show server quirks. + reader.ReadByte(); // DSCode.FixedIDByte + reader.ReadByte(); // DSCode.DataSerializable + reader.ReadByte(); // DSCode.Class + + // C6 — Read collection type name string. cppcache L3387: + // const auto isStructTypeImpl = input.readString(); + // Server uses CacheableASCIIString (87) for pure-ASCII class + // names; CacheableString (42) for non-ASCII. ReadShortString + // dispatches both forms. + var collectionTypeName = ReadShortString(reader, "collection type name"); + + // C7 — If type is the Java Struct, capture column field names. + // cppcache L3389-3401. Cross-chunk dedup: server may resend + // the same field-name list in every chunk; we keep only the + // first chunk's copy. + if (collectionTypeName == "org.apache.geode.cache.query.Struct") + { + // Phase 1.4 — StructSet wire shape requires T = QueryStruct. + // NewQuery's type guard accepts any wire-registered T or + // QueryStruct; the runtime mismatch (e.g. caller wrote + // IQuery for "SELECT id, name") can only be detected + // here, when server's collection type is observed. + if (typeof(T) != typeof(QueryStruct)) + { + throw new GeodeException( + $"Server returned a multi-column projection but " + + $"IQuery<{typeof(T).Name}> is not IQuery<{nameof(QueryStruct)}>."); + } + + var numOfFldNames = reader.ReadArrayLength(); + var skipDup = _structFieldNames.Count != 0; + for (var i = 0; i < numOfFldNames; i++) + { + // Field name uses the same short-string forms as C6. + var fieldName = ReadShortString(reader, "struct field name"); + if (!skipDup) + { + _structFieldNames.Add(fieldName); + } + } + } + + // C8 — Skip remaining bytes in the first (metadata) part. + // cppcache L3404-3406: input.reset(); advanceCursor(partLen + 5). + // Our AdvanceCursor is forward-only; C3-C7 read strictly + // within the first part body (Position ≤ partLen + 5), so + // the equivalent is a positive delta to the absolute target. + // 5 = i32 partLen header (4) + isObj byte (1). + var firstPartEnd = partLen + 5; + reader.AdvanceCursor(firstPartEnd - reader.Position); + + // C9 — Read second (data) part header. cppcache L3408-3417: + // input.readInt32(); // skip part length + // if (!input.read()) throw MessageException(...) + // We follow cppcache and drop the second part's length — + // bounds checking via the chunk-level length is enough. + reader.ReadInt32(); + var isObj = reader.ReadBool(); + if (!isObj) + { + throw new GeodeException( + "Query response part is not an object; possible serialization mismatch."); + } + + // C10 — isResultSet = (_structFieldNames.Count == 0). + // cppcache L3419. C7 only fills _structFieldNames when the + // collection type was the Java Struct, so empty = single- + // column ResultSet path; non-empty = multi-column StructSet. + var isResultSet = _structFieldNames.Count == 0; + + // C11 — Read array type DSCode + branch to C11a / C11b / C11c. + // cppcache L3421: auto arrayType = static_cast(input.read()); + var arrayType = reader.ReadByte(); + + if (arrayType == DSCode.CacheableObjectArray) + { + // C11a — Object[] inline. cppcache L3423-3440. + // readArrayLength → arraySize + // skipClass + // foreach of arraySize: + // if isResultSet: readObject → push + // else (StructSet): read 1 byte (skip), + // readArrayLength → arraySize2, + // skipClass, + // foreach: readObject → push + // (flattens N rows × K cols into a linear list) + var arraySize = reader.ReadArrayLength(); + + // skipClass — array's element-type class metadata. + // cppcache L3425. SkipClass body itself still NIE; the + // call site is wired for when S1-S4 lands. + SkipClass(reader); + + // Row loop. cppcache L3426-3440. + for (var arrayItem = 0; arrayItem < arraySize; arrayItem++) + { + if (isResultSet) + { + // ResultSet — one row = one value. cppcache: + // input.readObject(value); + // m_queryResults->push_back(value); + var value = serializationRegistry.ReadObject(reader); + _results.Add((T?)value); + } + else + { + // StructSet — one row = inner array of K field + // values. cppcache flattens into m_queryResults; + // we assemble a QueryStruct inline (Option C) and + // push one row at a time. T == QueryStruct + // verified by C7's guard. + // + // cppcache L3432-3439: + // input.read(); // marker, skip + // int32_t arraySize2 = input.readArrayLength(); + // skipClass(input); + // foreach: input.readObject(value); push to flat list + reader.ReadByte(); + var k = reader.ReadArrayLength(); + SkipClass(reader); + var fieldValues = new List(k); + for (var j = 0; j < k; j++) + { + fieldValues.Add(serializationRegistry.ReadObject(reader)); + } + var row = new QueryStruct(_structFieldNames, fieldValues); + _results.Add((T?)(object?)row); + } + } + } + else if (arrayType == DSCode.FixedIDByte) + { + // C11b — CacheableObjectPartList framed. + // cppcache L3441-3454: read FID byte; expect + // CacheableObjectPartList (25); delegate to readObjectPartList. + var fid = reader.ReadByte(); + if (fid != (byte)DSFid.CacheableObjectPartList) + { + throw new GeodeException( + $"ChunkedQueryResponse: expected CacheableObjectPartList " + + $"({(int)DSFid.CacheableObjectPartList}) inside FixedIDByte " + + $"frame, got FID {(sbyte)fid}."); + } + ReadObjectPartList(reader, isResultSet); + } + else + { + // C11c — unknown array type. cppcache L3455-3463. + throw new GeodeException( + $"ChunkedQueryResponse: unhandled message format DSCode {arrayType}; " + + "possible serialization mismatch."); + } + + // C12 — Drain auth trailer. cppcache L3465: + // m_msg.readSecureObjectPart(input, false, true, isLastChunkWithSecurity) + // Phase 1.4 no-op (msg = null). Phase 3 ports + // TcrMessage.ReadSecureObjectPart + wires it in here + C3a / C3b. + } + + public override void Reset() + { + // K1 — _results.Clear() cppcache L3292 + // K2 — _structFieldNames.Clear() cppcache L3293 + _results.Clear(); + _structFieldNames.Clear(); + } + + /// + /// cppcache ChunkedQueryResponse::readObjectPartList + /// (ThinClientRegion.cpp:3296-3345). Recursive: StructSet + /// nesting re-enters with =. + /// + private void ReadObjectPartList(DataInput reader, bool isResultSet) + { + // R1 — readBoolean — must be false. cppcache L3298-3301: + // if (input.readBoolean()) throw IllegalStateException( + // "Query response has keys which is unexpected."); + if (reader.ReadBool()) + { + throw new GeodeException( + "ChunkedQueryResponse::readObjectPartList: " + + "query response has keys which is unexpected."); + } + + // R2 — readInt32 → len. cppcache L3303: + // int32_t len = input.readInt32(); + var len = reader.ReadInt32(); + + // R3 — for each entry, read tag byte and branch: + // • tag == 2 → per-entry exception → throw + // • else if isResultSet → readObject, push single value + // • else → read inner CacheableObjectPartList of K fields, + // assemble QueryStruct, push as T? + // cppcache L3305-3344. The cppcache "recurse with + // isResultSet=true" path flattens K field values into the + // shared accumulator; we instead read them into a local + // buffer and build QueryStruct per row (Option C). + for (var i = 0; i < len; i++) + { + var tag = reader.ReadByte(); + if (tag == 2) + { + // R3a — per-entry exception. cppcache L3306-3309. + ReadExceptionAndThrow(reader); + } + + if (isResultSet) + { + // R3b — single value row. + var value = serializationRegistry.ReadObject(reader); + _results.Add((T?)value); + } + else + { + // R3c — inner CacheableObjectPartList = one Struct row. + // T == QueryStruct enforced by C7 (HandleObjectChunk). + // cppcache L3315-3341. + var dscode = reader.ReadByte(); + if (dscode != DSCode.FixedIDByte) + { + throw new GeodeException( + $"ChunkedQueryResponse: expected FixedIDByte for inner " + + $"struct-row marker, got DSCode {dscode}."); + } + var fid = reader.ReadByte(); + if (fid != (byte)DSFid.CacheableObjectPartList) + { + throw new GeodeException( + $"ChunkedQueryResponse: expected CacheableObjectPartList " + + $"({(int)DSFid.CacheableObjectPartList}) for inner struct-row, " + + $"got FID {(sbyte)fid}."); + } + + var fieldValues = ReadStructRow(reader); + var row = new QueryStruct(_structFieldNames, fieldValues); + _results.Add((T?)(object?)row); + } + } + } + + /// + /// Read an inner CacheableObjectPartList as one Struct row: + /// the K field values that follow the row's outer marker bytes. + /// Cppcache recurses with + /// isResultSet=true which flattens K values into + /// m_queryResults; we use a local buffer so caller can + /// build a single per row. + /// + private List ReadStructRow(DataInput reader) + { + // Inner header: readBoolean keys-flag (must be false) + + // readInt32 K. Mirrors cppcache readObjectPartList opening. + if (reader.ReadBool()) + { + throw new GeodeException( + "ChunkedQueryResponse::readObjectPartList (inner): " + + "query response has keys which is unexpected."); + } + var k = reader.ReadInt32(); + + var values = new List(k); + for (var j = 0; j < k; j++) + { + var tag = reader.ReadByte(); + if (tag == 2) + { + // Per-field exception. Same encoding as outer R3a. + ReadExceptionAndThrow(reader); + } + values.Add(serializationRegistry.ReadObject(reader)); + } + return values; + } + + /// + /// Handle the per-entry exception form. cppcache + /// ChunkedQueryResponse::readObjectPartList L3306-3309: + /// skip the type-metadata blob (advanceCursor(readArrayLength())) + /// then readString for the message and + /// throw IllegalStateException(msg). We accept only + /// for the message (Huge / + /// ASCII variants would also be valid cppcache wire shapes but + /// servers seldom use them for exception text). + /// + [System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ReadExceptionAndThrow(DataInput reader) + { + reader.AdvanceCursor(reader.ReadArrayLength()); + var msg = ReadShortString(reader, "per-entry exception message"); + throw new GeodeException( + $"ChunkedQueryResponse: server-side per-entry exception: {msg}"); + } + + /// + /// Read a DSCode-tagged short string. cppcache + /// DataInput::readString (DataInput.hpp:280-293) + /// dispatches on the DSCode tag to four readers; this helper + /// covers the two short forms + /// and . They share the + /// same wire prefix (u16 length + N bytes), and modified UTF-8 + /// decoding of ASCII bytes is byte-identical to ASCII decoding, + /// so a single + /// call + /// works for both. Huge variants land with the Phase 4 large-string + /// reader. + /// + private static string ReadShortString(DataInput reader, string context) + { + var dscode = reader.ReadByte(); + if (dscode == DSCode.CacheableString || dscode == DSCode.CacheableASCIIString) + { + return reader.ReadJavaModifiedUtf8(); + } + throw new GeodeException( + $"ChunkedQueryResponse: expected CacheableString (42) or " + + $"CacheableASCIIString (87) for {context}, got DSCode {dscode}."); + } + + /// + /// cppcache ChunkedQueryResponse::skipClass + /// (ThinClientRegion.cpp:3468-3480). Skips a Java + /// Class header in the wire stream. + /// + private static void SkipClass(DataInput reader) + { + // S1 — read DSCode; expect Class (43); else throw. cppcache + // L3469-3478: + // auto classByte = static_cast(input.read()); + // if (classByte != DSCode::Class) throw IllegalStateException(...); + var classByte = reader.ReadByte(); + if (classByte != DSCode.Class) + { + throw new GeodeException( + $"ChunkedQueryResponse::skipClass: did not get expected class " + + $"header byte, got DSCode {classByte}."); + } + + // S2 — read 1 byte (string type id; ignored, assume normal + // string < 64k). cppcache L3472: + // // ignore string type id - assuming its a normal (under 64k) string. + // input.read(); + reader.ReadByte(); + + // S3 — readInt16 → classLen. cppcache L3473: + // uint16_t classLen = input.readInt16(); + // cppcache casts the i16 to u16; we read u16 directly. Class + // name length is unsigned (can't be negative) — same wire + // bytes either way for the < 32k common case. + var classLen = reader.ReadUInt16(); + + // S4 — advance past the class name bytes. cppcache L3474: + // input.advanceCursor(classLen); + reader.AdvanceCursor(classLen); + } +} diff --git a/src/Geode.Client/Internal/ChunkedRemoveAllResponse.cs b/src/Geode.Client/Internal/ChunkedRemoveAllResponse.cs new file mode 100644 index 0000000..db636a7 --- /dev/null +++ b/src/Geode.Client/Internal/ChunkedRemoveAllResponse.cs @@ -0,0 +1,211 @@ +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// consumer for the chunked reply of a +/// request. Mirrors cppcache +/// ChunkedRemoveAllResponse +/// (cppcache/src/ThinClientRegion.hpp:588-610 + +/// cppcache/src/ThinClientRegion.cpp:3736-3786). +/// +/// +/// +/// Phase 1.3.b status: empty skeleton. Both +/// and throw +/// . The decoder body lands once +/// exists +/// (step 5 on the Phase 1.3.b todo). +/// +/// +/// Expected payload per chunk. cppcache handleChunk +/// distinguishes three shapes via TcrMessageHelper::readChunkPartHeader: +/// +/// +/// NULL_OBJECT — server has no result to ship +/// (empty batch or caching disabled). No accumulation, just +/// consume the secure-object trailer and return. +/// OBJECT (FixedIDByte + +/// DSFid.VersionedObjectPartList) — one +/// VersionedCacheableObjectPartList instance; merge into +/// the accumulating list via addAll. +/// BYTES (single-hop metadata refresh) — 2-byte +/// payload [metadataVersion][networkHopType]; trigger a +/// PR metadata refresh. Phase 4 (single-hop) work; Phase 1.3 +/// ignores this branch. +/// +/// +/// Phase 1.3 result. The public RemoveAllAsync contract +/// is plain — per-key version tags / miss +/// flags are discarded. The accumulating list is still built so the +/// dispatcher consumes the chunk bytes correctly; surfacing it lands +/// when client-side caching does (Phase 4+). +/// +/// +/// +/// Region this chunked op is against. Mirrors cppcache +/// ChunkedRemoveAllResponse::m_region +/// (std::shared_ptr<Region>). +/// +/// +/// The reply the handler reads +/// auth-trailer / pool / endpoint-mem-id off of. Mirrors cppcache +/// ChunkedRemoveAllResponse::m_msg (TcrMessage&). +/// +/// +/// Nullable in our port: cppcache constructs the reply ref +/// before the send and mutates it in place; our chunked +/// path synthesises the reply after the loop. The field is +/// kept for cppcache-shape parity but Phase 1.3 leaves it +/// null — the helpers cppcache reads off it +/// (getPool / getChunkedResultHandler / +/// readSecureObjectPart) are all Phase 3+ (auth) / Phase 4+ +/// (single-hop) territory. +/// +/// +/// Accumulating list of per-key (version, miss-flag) entries. +/// Mirrors cppcache +/// ChunkedRemoveAllResponse::m_list +/// (std::shared_ptr<VersionedCacheableObjectPartList>). +/// +/// +/// Empty-shell type until the wire decoder lands (Phase 4+). +/// Phase 1.3 leaves the field present but unread — per-key +/// result is dropped on the floor. +/// +internal sealed class ChunkedRemoveAllResponse( + IServiceProvider serviceProvider, + ILogger logger, + TcrMessageHelper tcrMessageHelper, + ThinClientRegion region, + TcrMessage? msg = null, + VersionedCacheableObjectPartList? list = null) : TcrChunkedResult +{ + public override void HandleChunk(ReadOnlyMemory payload, bool isLastChunk) + { + // Mirrors cppcache ChunkedRemoveAllResponse::handleChunk + // (cppcache/src/ThinClientRegion.cpp:3736-3786). + + // ─── Step 1: wrap chunk bytes ────────────────────────── + // cppcache: cacheImpl->createDataInput(chunk, chunkLen, pool). + // pool / cacheImpl back-refs aren't needed yet (Phase 4+ when + // single-hop / PDX type resolution lands). + var reader = ActivatorUtilities.CreateInstance(serviceProvider, payload); + + // ─── Step 2: read chunk part header ──────────────────── + // Peels partLen + isObj + DSCode/FixedID combo, classifies + // the chunk into NullObject / Object / Exception / Bytes. + // Expected leading DSCode = FixedIDByte (1-byte fixed-id + // follows); expected partType = DSFid.VersionedObjectPartList. + // + // The flags byte is reconstructed from the bool isLastChunk + // (Phase 1.3 no auth → security bit always 0); Phase 3 + // should change TcrChunkedResult.HandleChunk's signature to + // carry the raw flags byte instead. + var chunkType = tcrMessageHelper.ReadChunkPartHeader( + reader, + DSCode.FixedIDByte, + (int)DSFid.VersionedObjectPartList, + nameof(ChunkedRemoveAllResponse), + out var partLen, + isLastChunk: (byte)(isLastChunk ? 1 : 0)); + + // ─── Step 3a: NULL_OBJECT branch ─────────────────────── + // Server has no result (empty batch / caching disabled). No + // accumulation; just consume the secure-object trailer and + // return. cppcache LOGDEBUG mirrored. + if (chunkType == TcrMessageHelper.ChunkObjectType.NullObject) + { + logger.LogDebug("ChunkedRemoveAllResponse::handleChunk nullptr object"); + // TODO Phase 3+ — m_msg.readSecureObjectPart(reader, false, + // true, isLastChunkWithSecurity). Phase 1.3 no auth → + // security bit always 0 → no trailer bytes to consume. + return; + } + + // ─── Step 3b: OBJECT branch ─────────────────────────── + // cppcache constructs a fresh VersionedCacheableObjectPartList + // per chunk, decodes via fromData, then merges into the + // accumulating m_list via addAll. Both decoder + merge NIE + // today (Phase 4+). + if (chunkType == TcrMessageHelper.ChunkObjectType.Object) + { + logger.LogDebug("ChunkedRemoveAllResponse::handleChunk object"); + + // cppcache: new VersionedCacheableObjectPartList(region, dsmemId, responseLock). + // Phase 1.3 — endpointMemId always 0 (no single-hop); + // responseLock not threaded through (single-task chunk drain). + var vcObjPart = ActivatorUtilities.CreateInstance( + serviceProvider, region.SerializationRegistry, region); + vcObjPart.FromData(reader); + + // Phase 1.3 caller doesn't supply `list`, so the merge is + // a no-op — accumulated per-key results are dropped on the + // floor anyway (RemoveAllAsync returns plain Task). + list?.AddAll(vcObjPart); + + // TODO Phase 3+ — m_msg.readSecureObjectPart(reader, false, + // true, isLastChunkWithSecurity). + return; + } + + // ─── Step 3c: BYTES branch ──────────────────────────── + // Single-hop PR metadata refresh prelude: 2 raw bytes + // [metadataVersion][networkHopType]. Drain them so the wire + // reader stays aligned; the enqueue-for-refresh call + // (cppcache ThinClientRegion.cpp:3777-3784) is Phase 4+ work + // (needs ClientMetaDataService + ThinClientPoolDM.GetPool()). + if (chunkType == TcrMessageHelper.ChunkObjectType.Bytes) + { + logger.LogDebug("ChunkedRemoveAllResponse::handleChunk BYTES PART"); + var metadataVersion = reader.ReadByte(); + logger.LogDebug( + "ChunkedRemoveAllResponse::handleChunk single-hop bytes byte0 = {Byte0}", + metadataVersion); + var networkHopType = reader.ReadByte(); + + // TODO Phase 3+ — m_msg.readSecureObjectPart(...). + // TODO Phase 4+ — when metadataVersion != 0 and pool has + // PRSingleHopEnabled + ClientMetaDataService, enqueue: + // poolDM.ClientMetaDataService.EnqueueForMetadataRefresh( + // region.FullPath, networkHopType); + _ = metadataVersion; + _ = networkHopType; + return; + } + + // Fallthrough: ChunkObjectType.Exception (or unforeseen + // value). cppcache flips reply.MessageType to EXCEPTION + // inside readChunkPartHeader and lets the caller's reply + // switch handle it; our TcrMessage record is immutable so we + // can't propagate that way — throw and let the chunked + // reader unwind to ThinClientRegion.RemoveAllAsync's + // EXCEPTION switch. + _ = partLen; + _ = region; + _ = msg; + throw new GeodeException( + $"ChunkedRemoveAllResponse.HandleChunk: unhandled chunkType={chunkType}."); + } + + public override void Reset() + { + // Mirrors cppcache ChunkedRemoveAllResponse::reset + // (cppcache/src/ThinClientRegion.cpp:3729-3733). + + // ─── Step 1: null + size guard ─────────────────────── + if (list is null || list.Size <= 0) + { + return; + } + + // ─── Step 2: clear inner versionTags vector ONLY ───── + // Does NOT null the _list reference, does NOT clear other + // fields — cppcache keeps the same _list instance so retries + // reuse the accumulator. + list.VersionTags.Clear(); + } +} + diff --git a/src/Geode.Client/Internal/ClientConnectionRequest.cs b/src/Geode.Client/Internal/ClientConnectionRequest.cs new file mode 100644 index 0000000..9e5a31f --- /dev/null +++ b/src/Geode.Client/Internal/ClientConnectionRequest.cs @@ -0,0 +1,37 @@ +using Geode.Client.Protocol; + +namespace Geode.Client.Internal; + +/// +/// Locator wire request: "give me a server for a new forward (client ??/// server) connection". Mirrors cppcache +/// ClientConnectionRequest +/// (cppcache/src/ClientConnectionRequest.hpp/.cpp). +/// +/// +/// Wire tag: . Body is +/// writeString(serverGroup) followed by an i32 count of +/// excluded server locations, each serialised as cppcache +/// ServerLocation::toData (writeString(host) + +/// writeInt(port)). The outer locator frame (gossip version + +/// Geode version + DSCode-tagged FixedId envelope) is the +/// 's responsibility. +/// +internal sealed record ClientConnectionRequest( + string ServerGroup, + IReadOnlyCollection ExcludedServers) +{ + /// Mirrors cppcache ClientConnectionRequest::toData (ClientConnectionRequest.cpp:27-30) + writeSetOfServerLocation (:36-46). + public void WriteTo(DataOutput writer) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WriteString(ServerGroup); + + writer.WriteInt32(ExcludedServers.Count); + foreach (var loc in ExcludedServers) + { + // cppcache ServerLocation::toData (ServerLocation.hpp:68-71) + writer.WriteString(loc.Host); + writer.WriteInt32(loc.Port); + } + } +} diff --git a/src/Geode.Client/Internal/ClientConnectionResponse.cs b/src/Geode.Client/Internal/ClientConnectionResponse.cs new file mode 100644 index 0000000..5b5a985 --- /dev/null +++ b/src/Geode.Client/Internal/ClientConnectionResponse.cs @@ -0,0 +1,45 @@ +using Geode.Client.Protocol; + +namespace Geode.Client.Internal; + +/// +/// Locator wire response to : a +/// single server location (host/port) plus a "found" flag. Mirrors +/// cppcache ClientConnectionResponse +/// (cppcache/src/ClientConnectionResponse.hpp/.cpp). +/// +/// +/// Wire tag: . Body is a +/// bool serverFound; when is +/// , the bool is followed by a single +/// pair (readString(host) + +/// readInt32(port)). When , the locator +/// reachability succeeded but the cluster currently has no server +/// matching the requested group — caller should treat it differently +/// from a transport error (cppcache locatorFound=true branch in +/// getEndpointForNewFwdConn). +/// +internal sealed record ClientConnectionResponse( + bool ServerFound, + ServerLocation? Server) +{ + /// Mirrors cppcache ClientConnectionResponse::fromData (ClientConnectionResponse.cpp:28-33). + public static ClientConnectionResponse ReadFrom(DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + + var serverFound = reader.ReadBool(); + if (!serverFound) + { + return new ClientConnectionResponse(ServerFound: false, Server: null); + } + + // cppcache ServerLocation::fromData: readString + readInt32. + var host = reader.ReadString() ?? string.Empty; + var port = reader.ReadInt32(); + return new ClientConnectionResponse( + ServerFound: true, + Server: new ServerLocation(host, port)); + } +} + diff --git a/src/Geode.Client/Internal/ClientMetadataService.cs b/src/Geode.Client/Internal/ClientMetadataService.cs new file mode 100644 index 0000000..dd82a11 --- /dev/null +++ b/src/Geode.Client/Internal/ClientMetadataService.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Pool-scoped service that maintains client-side metadata for +/// partitioned regions (bucket → server location mapping) so the pool +/// can route PR ops single-hop to the bucket primary instead of going +/// via any server. Mirrors cppcache ClientMetadataService +/// (cppcache/src/ClientMetadataService.hpp/.cpp). +/// +/// +/// Phase 4 (PR single-hop) entry point. cppcache full surface includes +/// metadata refresh thread, bucket-server resolution, server-to-keys +/// grouping for batched ops, and primary-timeout/secondary-fallback +/// handling. This class currently has only the lifecycle stubs +/// ( / ) so +/// can wire the +/// call site now; the body grows with Phase 4 work. +/// +internal sealed class ClientMetadataService( + ThinClientPoolDM pool, + ILogger logger) +{ + private readonly ThinClientPoolDM _pool = pool; + private readonly ILogger _logger = logger; + + /// + /// Launch the background metadata-refresh task. Mirrors cppcache + /// ClientMetadataService::start() + /// (ClientMetadataService.cpp) — spawns the + /// svc() loop that processes the metadata-refresh queue. + /// + public Task StartAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + // TODO Phase 4: spawn the metadata-refresh background task; the + // loop pulls region paths off the refresh queue and issues + // GetClientPRMetadataRequest / GetClientPartitionAttributesRequest. + // Currently a no-op so ThinClientPoolDM.StartBackgroundThreads can + // wire the call site (walking-skeleton). + return Task.CompletedTask; + } + + /// + /// Stop the background metadata-refresh task. Mirrors cppcache + /// ClientMetadataService::stop(). + /// + public Task StopAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + // TODO Phase 4: signal the refresh loop to exit, await its + // graceful shutdown, drop cached metadata. Currently a no-op so + // ThinClientPoolDM.DestroyAsync can wire the call site + // (walking-skeleton). + return Task.CompletedTask; + } + + /// + /// Drop any cached bucket → server mapping that points at the + /// endpoint named , called by + /// after an IO / timeout failure + /// against that endpoint. Mirrors cppcache + /// ClientMetadataService::removeBucketServerLocation. + /// + public void RemoveBucketServerLocation(string endpointName) + { + // TODO Phase 4: locate every BucketServerLocation whose name + // matches and evict it from the bucket → primary/secondary maps; + // the next op against the same bucket will trigger a metadata + // refresh. No-op until Phase 4 builds the maps (walking-skeleton). + _ = endpointName; + } +} diff --git a/src/Geode.Client/Internal/EventIdGenerator.cs b/src/Geode.Client/Internal/EventIdGenerator.cs new file mode 100644 index 0000000..37b6cda --- /dev/null +++ b/src/Geode.Client/Internal/EventIdGenerator.cs @@ -0,0 +1,113 @@ +namespace Geode.Client.Internal; + +/// +/// Per-cache generator for the (threadId, sequenceId) pair the +/// server uses to dedup write events. Mirrors cppcache +/// EventIdTSS (cppcache/src/EventId.cpp:42-77) — the +/// thread_local singleton that hands every writeEventIdPart +/// a fresh id pair. +/// +/// +/// +/// Wire role. Each Put / PutAll / Destroy / +/// Invalidate request carries an EventId part containing +/// these two i64 values. The server dedups by +/// (clientId, threadId, sequenceId): replaying the same triple +/// is silently dropped. clientId is process-static (see +/// ), so uniqueness here +/// boils down to ensuring no two write events in the same cache +/// observe the same (threadId, sequenceId). +/// +/// +/// Why we don't mirror cppcache exactly. cppcache assigns a +/// monotonic global threadId to each OS thread the first time +/// it touches the TSS, then increments a thread-local +/// sequenceId. That design assumes thread affinity for the +/// lifetime of a logical operation — which C# async breaks. Awaiting +/// can resume on a different pool thread, so a +/// ThreadLocal<long> here would silently collide. +/// +/// +/// Simpler scheme that preserves the contract: one fixed +/// (= 1) per cache scope plus a single +/// on +/// _sequenceId. Each call to hands back +/// (1, ++_sequenceId). Globally unique inside this cache, no +/// thread-affinity assumption, no awaitable hazard. Same wire effect +/// as cppcache (server still dedups on the triple) — we just collapse +/// the cppcache's two-level (threadId × seq) namespace into a single +/// long counter. +/// +/// +/// Scope: Scoped (per-cache). Different +/// instances do not share a counter — +/// each cache has its own random uniqueTag in +/// , so the +/// clientId the server sees differs per cache and the +/// (clientId, threadId, seq) dedup triple is naturally unique +/// across caches even when both reset seq from 0. Mirrors cppcache: +/// EventIdTSS lives inside CacheImpl (no static +/// state), and each CacheImpl also owns its own +/// ClientProxyMembershipIDFactory.randString_. +/// +/// +internal sealed class EventIdGenerator +{ + /// + /// The fixed thread component every event in this cache reports. + /// cppcache uses a per-OS-thread value here; we collapse to a + /// single constant because async work can't carry thread affinity + /// across awaits (see class remarks). + /// + public const long ThreadId = 1L; + + private long _sequenceId; + + /// + /// Allocate the next (threadId, sequenceId) pair. Thread-safe + /// — concurrent callers always receive distinct sequence ids. + /// + public (long ThreadId, long SequenceId) Next() + { + var seq = Interlocked.Increment(ref _sequenceId); + return (ThreadId, seq); + } + + /// + /// Atomically reserve consecutive sequence + /// ids and return the lowest of the reserved range as + /// BaseSequenceId. The caller logically owns the contiguous + /// block [BaseSequenceId, BaseSequenceId + count - 1] — no + /// concurrent / call can + /// land inside that range. + /// + /// + /// + /// Mirrors cppcache writeEventIdPart(reserveSize) + /// (cppcache/src/TcrMessage.cpp:834-842) which constructs an + /// EventId with reserveSize = keys.size() - 1 for + /// PutAll / RemoveAll. cppcache puts a single + /// (threadId, baseSeq) pair on the wire but bumps the + /// per-thread sequence by N-1 extra so the server can dedup + /// each key's logical event as (clientId, threadId, baseSeq+i) + /// for i ∈ [0, N). + /// + /// + /// Single — the + /// caller's reserved window is guaranteed contiguous even under + /// concurrent bulk ops (no risk of two bulk ops interleaving their + /// per-key dedup keys). + /// + /// + /// Number of sequence ids to reserve. Must be + /// ≥ 1; matches the bulk-op contract (empty batches are + /// rejected upstream at the builder / public API). + public (long ThreadId, long BaseSequenceId) NextRange(int count) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1); + // Atomically advance by `count`; Add returns the post-add value, + // so the reserved ids are [end - count + 1, end] inclusive. + var end = Interlocked.Add(ref _sequenceId, count); + return (ThreadId, end - count + 1); + } +} diff --git a/src/Geode.Client/Internal/GeodeCache.cs b/src/Geode.Client/Internal/GeodeCache.cs new file mode 100644 index 0000000..d4762a6 --- /dev/null +++ b/src/Geode.Client/Internal/GeodeCache.cs @@ -0,0 +1,698 @@ +using System.Collections.Concurrent; +using Geode.Client.Options; +using Geode.Client.Pdx; +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Internal; + +internal sealed class GeodeCache : IGeodeCache, IAsyncDisposable +{ + private int _destroyPending; + private readonly SemaphoreSlim _initLock = new(1, 1); + private Task? _initTask; + private readonly string _name; + private readonly IServiceProvider _serviceProvider; + private readonly Lazy _poolManager; + private readonly ConcurrentDictionary _regions = new(StringComparer.Ordinal); + private readonly SystemProperties _systemProperties; + private readonly Lazy _tcrConnectionManager; + private readonly TypedResultAdapter _typedResultAdapter; + private readonly TypeRegistry _typeRegistry; + private readonly PdxTypeRegistry _pdxTypeRegistry; + private readonly Lazy _serializationRegistry; + private readonly EventIdGenerator _eventIdGenerator = new(); + public GeodeCache(IServiceProvider serviceProvider, string name, GeodeClientOptions? options = null) + { + _name = name; + _serviceProvider = serviceProvider; + _systemProperties = BuildSystemProperties(options); + _typedResultAdapter = ActivatorUtilities.CreateInstance(serviceProvider); + _typeRegistry = ActivatorUtilities.CreateInstance(serviceProvider); + _pdxTypeRegistry = ActivatorUtilities.CreateInstance(serviceProvider); + _poolManager = new Lazy( + () => ActivatorUtilities.CreateInstance(serviceProvider, this), + LazyThreadSafetyMode.ExecutionAndPublication); + _tcrConnectionManager = new Lazy( + () => ActivatorUtilities.CreateInstance(serviceProvider, this), + LazyThreadSafetyMode.ExecutionAndPublication); + + _serializationRegistry = new Lazy( + () => ActivatorUtilities.CreateInstance(serviceProvider, this), + LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Build-time snapshot of the public + /// into the internal bag (cppcache + /// "geode.properties → SystemProperties at cache build"). Subsequent + /// mutations to the caller's do NOT affect + /// this cache. + /// + private static SystemProperties BuildSystemProperties(GeodeClientOptions? opts) + { + if (opts is null) return new SystemProperties(); + + // ── init-only properties: object initializer ──────────────── + var sp = new SystemProperties + { + Name = opts.Name, + ThreadPoolSize = opts.ThreadPoolSize, + + // Subscription + DurableClientId = opts.Subscription.DurableClientId, + DurableTimeout = opts.Subscription.DurableTimeout, + AutoReadyForEvents = opts.Subscription.AutoReadyForEvents, + RedundancyMonitorInterval = opts.Subscription.RedundancyMonitorInterval, + NotifyAckInterval = opts.Subscription.NotifyAckInterval, + NotifyDupCheckLife = opts.Subscription.NotifyDupCheckLife, + + // Security + SecurityClientDhAlgo = opts.Security.ClientDhAlgo, + SecurityClientKsPath = opts.Security.ClientKsPath, + SecurityProperties = opts.Security.Properties, + + // Heap (LRULimit: ulong public ↔ long internal — wire is i64 BE, + // cast is safe within the positive-i64 range we care about). + HeapLRULimit = (long)opts.Heap.LRULimit, + HeapLRUDelta = opts.Heap.LRUDelta, + + // Tls — only Enabled has a SystemProperties analog today; + // KeyStorePath / Password / TrustStorePath wire in when the + // SSL handshake path lands (Phase 3+). + SslEnabled = opts.Tls.Enabled, + + // Pool (these are pool-level wire knobs surfaced under + // SystemProperties for cppcache parity — PoolAttributes + // owns the per-pool overrides). + ConnectionPoolSize = (uint)opts.Pool.ConnectionPoolSize, + ConnectTimeout = opts.Pool.ConnectTimeout, + ConnectWaitTimeout = opts.Pool.ConnectWaitTimeout, + MaxSocketBufferSize = opts.Pool.MaxSocketBufferSize, + PingInterval = opts.Pool.PingInterval, + BucketWaitTimeout = opts.Pool.BucketWaitTimeout, + DisableShufflingEndpoint = !opts.Pool.ShuffleEndpoints, // inverted (cppcache parity) + }; + + // ── { get; set; } properties: assign after init-block ──────── + sp.MaxDepth = opts.Serialization.MaxDepth; + sp.MaxArrayLength = opts.Serialization.MaxArrayLength; + sp.MaxBytesLength = opts.Serialization.MaxBytesLength; + sp.MaxStringLength = opts.Serialization.MaxStringLength; + + // TODO Phase 2+ — fields without a SystemProperties analog today: + // opts.EnableChunkHandlerThread (.NET ThreadPool covers it, may stay unmapped) + // opts.Tls.KeyStorePath/Password/TrustStorePath (SSL handshake) + // opts.Subscription.ConflateEvents (subscription queue settings) + // opts.Heap.TombstoneTimeout (concurrency-checks / tombstones) + // opts.Pdx.ClearTypeIdsOnDisconnect (PDX type registry) + // opts.Tx.SuspendedTimeout (transactions, Phase 11+) + + return sp; + } + + private async Task InitializeCoreAsync(CancellationToken ct) + { + ObjectDisposedException.ThrowIf(IsClosed, this); + // ── 2. TCCM init ──────────────────────────────────────── + // Sets _isDurable from options.Subscription. In pool mode + // (our MVP) the three background workers stay parked; this + // is essentially a flag flip. Must complete before any pool + // queries TCCM.IsDurable / haEnabled. + await _tcrConnectionManager.Value.InitAsync(isPool: true, ct).ConfigureAwait(false); + + // ── 3-5. Build and init pools ─────────────────────────── + // Both paths produce a sequence of CachePoolOptions; the + // foreach below builds + inits each one uniformly. Multi-pool / + // multi-server / locator gating now lives inside + // ThinClientPoolDM's ctor, so Cache stays generic. Required- + // field validation is the Options layer's job (Phase 1.1 收尾); + // here we trust the input. + //if (_options.Cache is null) + //{ + // // path (b) — Options-based (programmatic, the default). + // // TODO step 3.b: enumerate a yet-to-be-added programmatic + // // pool-config surface (e.g. _options.Pools) and project + // // into CachePoolOptions-shape items. + // throw new NotImplementedException( + // "TODO: Cache.InitializeCoreAsync step 3.b (path b — Options-based)"); + //} + //else + //{ + // // path (a) — Declarative cache.xml-style. Mirrors cppcache + // // CacheImpl::initializeDeclarativeCache(xml). + // await InitializeDeclarativeCacheAsync(_options.Cache, ct).ConfigureAwait(false); + //} + + // ── 7. PDX / serialization registration (Phase 2+) ────── + // TODO: if (_options.Cache?.Pdx is { } pdx) apply pdx + // ignoreUnreadFields / readSerialized to _pdxTypeRegistry. + } + + internal async Task InitializeAsync(CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(IsClosed, this); + var task = Volatile.Read(ref _initTask); + if (task is null) + { + await _initLock.WaitAsync(ct).ConfigureAwait(false); + try + { + task = _initTask; + if (task is null) + { + task = InitializeCoreAsync(ct); + Volatile.Write(ref _initTask, task); + } + } + finally + { + _initLock.Release(); + } + } + await task.WaitAsync(ct).ConfigureAwait(false); + } + + internal SystemProperties CacheProperties => _systemProperties; + + internal TypeRegistry TypeRegistry => _typeRegistry; + internal PdxTypeRegistry PdxTypeRegistry => _pdxTypeRegistry; + internal SerializationRegistry SerializationRegistry => _serializationRegistry.Value; + internal TcrConnectionManager ConnectionManager => _tcrConnectionManager.Value; + internal EventIdGenerator EventIdGenerator => _eventIdGenerator; + + // /// + // /// Build pools and regions from an already-bound + // /// tree. Mirrors cppcache + // /// CacheImpl::initializeDeclarativeCache(const std::string&) + // /// — the difference is we work off already-parsed options instead + // /// of running an XML parser (Xerces is bucket 1, cut per + // /// CLAUDE.md). + // /// + // /// + // /// Two passes: pools first (so regions can resolve their pool + // /// references), then regions. Each pool's InitAsync opens + // /// real sockets — this is where I/O actually fires. + // /// + // private async Task InitializeDeclarativeCacheAsync(CacheOptions cache, CancellationToken ct) + // { + // await InitializePoolsAsync(cache, ct).ConfigureAwait(false); + + // // ── 6. Build regions ──────────────────────────────────── + // // cppcache equivalent: CacheParser::create iterates + // // elements and calls CacheImpl::createRegion(name, + // // attrs) for each top-level region (sub-regions handled + // // recursively in the parser itself). + // foreach (var xmlRegion in cache.Regions) + // { + // // Name structural validation (non-empty / non-whitespace) + // // and RefId existence are enforced by + // // GeodeClientOptionsValidator at host build time — no + // // inline checks needed here. + + // // ── 6.1 Resolve refid template ───────────────── + // // cppcache CacheParser folds onto + // // a previously declared at + // // parse time (CacheParser.cpp:777-786). We do the same + // // here: clone the template, then let xmlRegion.Attributes + // // override non-null / non-empty fields. + // var attributes = ResolveAttributes(xmlRegion, cache.NamedAttributes); + + // // ── 6.2 Resolve pool ─────────────────────────── + // // cppcache CacheImpl::createRegion_internal + // // (CacheImpl.cpp:524) looks up the pool by name; empty + // // PoolName falls through to PoolManager.DefaultPool + // // (Find("") returns DefaultPool). + // var pool = poolManager.Find(attributes.PoolName); + // if (pool is null) + // { + // // Either PoolName references a pool not declared in + // // Cache.Pools, or PoolName is empty and no pools + // // are registered (the validator should have caught + // // the second case; defensive guard). + // throw new InvalidOperationException( + // $"Region '{xmlRegion.Name}' references pool " + + // $"'{attributes.PoolName}' which is not registered " + + // "(empty PoolName resolves to the default pool)."); + // } + + // // ── 6.3 IPool → ThinClientBaseDM ─────────────── + // // MVP has only one IPool impl (ThinClientPoolDM, which + // // IS-A ThinClientBaseDM), so the cast is always safe + // // today. The pattern-match form gives a clearer error + // // message if a future non-DM IPool implementation + // // arrives (Phase 1.5+) than a raw InvalidCastException. + // if (pool is not ThinClientBaseDM dm) + // { + // throw new InvalidOperationException( + // $"Pool '{attributes.PoolName}' " + + // $"({pool.GetType().Name}) does not derive from " + + // $"{nameof(ThinClientBaseDM)}; cannot be used as a " + + // "region's distribution manager."); + // } + + // // ── 6.4 Build ThinClientRegion ───────────────── + // // Phase 1.2 builds top-level regions only — `parent` is + // // always null until sub-region creation lands. + // // ActivatorUtilities can't match a null arg against the + // // `RegionInternal?` ctor slot (params object[] erases the + // // type), so resolve the logger from DI manually and call + // // the ctor directly. Mirrors what ActivatorUtilities would + // // have done minus the broken null-arg matching. + // var region = ActivatorUtilities.CreateInstance( + // serviceProvider, + // xmlRegion.Name, + // attributes, + // dm); + + // // ── 6.5 Register ─────────────────────────────── + // // cppcache CacheImpl::createRegion throws + // // RegionExistsException when m_regions already holds + // // the name. Future: GeodeClientOptionsValidator should + // // also flag duplicate names in Cache.Regions at + // // startup so this guard becomes pure belt-and-braces. + // if (!_regions.TryAdd(xmlRegion.Name, region)) + // { + // throw new InvalidOperationException( + // $"Region '{xmlRegion.Name}' is declared more than once " + + // "in Cache.Regions."); + // } + + // // ── 6.6 Sub-region children ──────────────────── + // if (xmlRegion.ChildRegions.Count > 0) + // { + // // TODO: recurse into ChildRegions and build each as + // // a sub-region of `region`. Mirrors cppcache + // // CacheParser walking nested elements + // // and calling RegionInternal::createSubregion on + // // the parent. Currently throws so XML-declared + // // sub-regions aren't silently dropped. + // throw new NotImplementedException( + // $"Region '{xmlRegion.Name}' declares " + + // $"{xmlRegion.ChildRegions.Count} sub-region(s); " + + // "sub-region creation is deferred to a later phase."); + // } + // } + // } + + // /// + // /// Build and initialise every declared pool (or the synthesized + // /// default pool when is set + // /// instead). Real TCP / handshake fires inside each + // /// InitAsync. + // /// + // /// + // /// cppcache <client-cache endpoints="..."> maps to + // /// poolFactory_->addServer(...) + // /// (CacheXmlParser.cpp:553-560). The validator guarantees + // /// and + // /// are mutually exclusive, so + // /// exactly one branch fires. The synthesized pool is built into + // /// a local list — we don't mutate the shared options instance, + // /// which would bleed across caches built from the same + // /// IOptionsMonitor snapshot. + // /// + // private async Task InitializePoolsAsync(CacheOptions cache, CancellationToken ct) + // { + // foreach (var xmlPool in ResolvePoolsToBuild(cache)) + // { + // // ctor enforces Phase 1.5 deferred limits (multi-server + // // / locator) internally; here we just hand it the pool + // // config and the shared TCCM. Positional args match + // // ThinClientPoolDM's primary ctor (xmlPool + options + + // // TCCM); ILogger is filled by DI. + // var pool = ActivatorUtilities.CreateInstance( + // serviceProvider, xmlPool, _options, tcrConnectionManager); + // poolManager.AddPool(xmlPool.Name, pool); + + // // Pool.InitAsync internally: + // // • locator query → endpoint list, OR direct server list + // // • foreach endpoint → TcrEndpoint.CreateNewConnectionAsync(...) + // // • socket open + handshake bytes + // // • receive server-issued uniqueId + // // • mark pool ready + // await pool.InitAsync(ct).ConfigureAwait(false); + // } + // } + + // /// + // /// Apply a refid template (if any) and merge the region's inline + // /// attribute overrides on top. Mirrors cppcache + // /// CacheParser refid handling + // /// (CacheParser.cpp:777-786): non-empty + // /// clones the named + // /// template; inline + // /// then overrides each field that is non-null (for value-type + // /// nullables) or non-empty (for plain strings). + // /// + // /// + // /// + // /// Chained refid is not honoured — a template's own + // /// is ignored; + // /// templates must be self-contained. + // /// + // /// + // /// Returns 's + // /// verbatim (same + // /// reference) when there is no RefId — no merge work, no + // /// allocation. + // /// + // /// + // private static CacheRegionAttributesOptions ResolveAttributes( + // CacheRegionOptions xmlRegion, + // IReadOnlyDictionary namedAttributes) + // { + // if (string.IsNullOrEmpty(xmlRegion.RefId)) + // { + // return xmlRegion.Attributes; + // } + + // // Validator already enforces RefId membership; defensive guard + // // covers callers that bypass DI validation. + // if (!namedAttributes.TryGetValue(xmlRegion.RefId, out var template)) + // { + // throw new InvalidOperationException( + // $"Region '{xmlRegion.Name}' RefId='{xmlRegion.RefId}' " + + // "does not match any key in Cache.NamedAttributes."); + // } + + // var inline = xmlRegion.Attributes; + // return new CacheRegionAttributesOptions + // { + // // Nullable value types: inline non-null wins. + // CachingEnabled = inline.CachingEnabled ?? template.CachingEnabled, + // CloningEnabled = inline.CloningEnabled ?? template.CloningEnabled, + // Scope = inline.Scope ?? template.Scope, + // InitialCapacity = inline.InitialCapacity ?? template.InitialCapacity, + // LoadFactor = inline.LoadFactor ?? template.LoadFactor, + // ConcurrencyLevel = inline.ConcurrencyLevel ?? template.ConcurrencyLevel, + // LruEntriesLimit = inline.LruEntriesLimit ?? template.LruEntriesLimit, + // DiskPolicy = inline.DiskPolicy ?? template.DiskPolicy, + // ClientNotification = inline.ClientNotification ?? template.ClientNotification, + // ConcurrencyChecksEnabled = inline.ConcurrencyChecksEnabled ?? template.ConcurrencyChecksEnabled, + + // // Plain strings: inline non-empty wins. + // Endpoints = string.IsNullOrEmpty(inline.Endpoints) ? template.Endpoints : inline.Endpoints, + // PoolName = string.IsNullOrEmpty(inline.PoolName) ? template.PoolName : inline.PoolName, + + // // Inner RefId is not honoured (mirrors decision in + // // CacheRegionAttributesOptions doc); leave empty so the + // // resolved attributes don't accidentally trigger a second + // // round of resolution somewhere. + // RefId = string.Empty, + + // // Reference types: inline non-null replaces wholesale (no deep merge). + // RegionTimeToLive = inline.RegionTimeToLive ?? template.RegionTimeToLive, + // RegionIdleTime = inline.RegionIdleTime ?? template.RegionIdleTime, + // EntryTimeToLive = inline.EntryTimeToLive ?? template.EntryTimeToLive, + // EntryIdleTime = inline.EntryIdleTime ?? template.EntryIdleTime, + // PartitionResolver = inline.PartitionResolver ?? template.PartitionResolver, + // CacheLoader = inline.CacheLoader ?? template.CacheLoader, + // CacheListener = inline.CacheListener ?? template.CacheListener, + // CacheWriter = inline.CacheWriter ?? template.CacheWriter, + // PersistenceManager = inline.PersistenceManager ?? template.PersistenceManager, + // }; + // } + + // /// + // /// Pure projection from to the list of + // /// pools the cache should build. When + // /// is non-empty, synthesises a single "default"-named + // /// whose + // /// is a deep copy of the endpoint list; otherwise returns + // /// as-is. Validator guarantees + // /// the two are mutually exclusive. + // /// + // internal static IReadOnlyList ResolvePoolsToBuild(CacheOptions cache) + // { + // if (cache.Endpoints.Count == 0) return cache.Pools; + + // return + // [ + // new() + // { + // Name = "default", + // Servers = cache.Endpoints.Select(e => e.Clone()).ToList(), + // }, + // ]; + // } + + ///// + ///// Test-only escape hatch: expose the scoped + ///// so integration tests can reach + ///// internals (e.g. PoolSize) without DI scope wrangling. Not + ///// part of the public API — gated by InternalsVisibleTo. + ///// + //internal PoolManager PoolManager => poolManager; + + public async Task CloseAsync(CancellationToken ct = default) + { + if (IsClosed) return; // idempotent + + // Mirror cppcache CacheImpl::close() ordering: + // TODO Phase 1.5: TCCM.CloseAsync — stop background workers + // (m_tcrConnectionManager->close() comes first in cppcache so + // scheduled ping tasks can't fire on torn-down state). + // TODO Phase 1.2: destroy regions (region drop happens between + // TCCM stop and pool close in cppcache). + // + // Pool drain — cascades pool.DestroyAsync into each + // ThinClientPoolDM (cancels its conn-management loop, releases + // timers, drains connections). PoolManager.CloseAsync is + // internally idempotent so a later DI-scope dispose is safe. + // + // IsValueCreated guard: if no one ever read `PoolManager`, the + // Lazy never materialised, so there's nothing to drain — skip + // force-building one on the dispose path. (Critical when + // DisposeAsync fires during ServiceProvider teardown: the SP is + // already disposed and ActivatorUtilities.CreateInstance would + // throw ObjectDisposedException.) + if (_poolManager.IsValueCreated) + { + await _poolManager.Value.CloseAsync(keepAlive: false, ct).ConfigureAwait(false); + } + IsClosed = true; + } + + public async ValueTask DisposeAsync() + { + // Forward to CloseAsync; idempotent until connection logic lands. + await CloseAsync().ConfigureAwait(false); + + // TCCM is now DI-Scoped — the per-cache AsyncServiceScope + // disposes it for us in reverse-resolve order, after Cache. + // PoolManager / ClientProxyMembershipIdBuilder / CacheScopeContext + // ride the same cascade. + + _initLock.Dispose(); + } + + /// + /// Mirrors cppcache CacheImpl::getRegion + /// (cppcache/src/CacheImpl.cpp:475-518) line-for-line: + /// throwIfClosed, m_destroyPending check (returns null), path + /// validation, leading-slash strip, first-segment lookup, + /// sub-region recursion via region->getSubregion(remainder). + /// + public IRegion? GetRegion(string path) + { + ArgumentNullException.ThrowIfNull(path); + + // cppcache: throwIfClosed + ObjectDisposedException.ThrowIf(IsClosed, this); + + // cppcache lock_guard(m_destroyCacheMutex) is unnecessary — + // ConcurrentDictionary covers map-side races, and + // _destroyPending is a single atomic int. + if (Volatile.Read(ref _destroyPending) != 0) + { + // cppcache CacheImpl.cpp:483 — silent null when destroy is + // mid-flight, distinct from throwIfClosed (which fires + // after IsClosed flips true). + return null; + } + + // cppcache: path == "/" || path.length() < 1 → + // IllegalArgumentException("Cache::getRegion: path is empty + // or a /"). We split into ArgumentException for empty (BCL + // ArgumentException.ThrowIfNullOrEmpty) and for "/". + ArgumentException.ThrowIfNullOrEmpty(path); + if (path == "/") + { + throw new ArgumentException("Cache.GetRegion: path is empty or '/'.", nameof(path)); + } + + // cppcache: strip a single leading "/". + var fullname = path.StartsWith('/') ? path[1..] : path; + + // cppcache: split at first '/'; left segment is the root region + // name, the rest (if any) is the sub-region path. + var idx = fullname.IndexOf('/'); + var stepname = idx < 0 ? fullname : fullname[..idx]; + + // cppcache findRegion(stepname): pure map lookup. + if (!_regions.TryGetValue(stepname, out var region)) + { + return null; + } + + if (idx >= 0) + { + // cppcache CacheImpl.cpp:504 — recurse into sub-region tree. + // var remainder = fullname[(idx + 1)..]; + // region = region.GetSubregion(remainder); + // TODO sub-region phase: IRegion has no GetSubregion yet; + // add it once the sub-region API surfaces. Until then, + // any path with an interior '/' falls through to NIE so + // callers don't silently get the root when they asked + // for a child. + throw new NotImplementedException( + $"Sub-region path '{path}' not yet supported; sub-region " + + "API lands in a future phase."); + } + + // TODO Phase 3 multi-user: cppcache CacheImpl.cpp:509-514 — + // if (isPoolInMultiuserMode(*region)) LOGWARN("...attached + // with region ... is in multiuser authentication mode..."). + + return region; + } + + public IRegion? GetRegion(string path) + where TKey : IEquatable + { + // Untyped lookup does the cppcache-faithful work (path validation, + // sub-region recursion, destroyPending check). RegionView is a + // pure compile-time wrapper — TKey/TValue are not runtime-bound. + var region = GetRegion(path); + return region is null ? null : new RegionView(region, _typedResultAdapter); + } + + /// + /// Mirrors cppcache Cache::createRegionFactory(RegionShortcut) + /// (cppcache/src/Cache.cppCacheImpl::createRegionFactory). + /// Direct rather than ActivatorUtilities — we + /// already hold every ctor arg, and the cache instance is the natural + /// back-pointer for the factory's eventual register-on-create step. + /// + public RegionFactory CreateRegionFactory(RegionShortcut shortcut) + { + ObjectDisposedException.ThrowIf(IsClosed, this); + return new RegionFactory(_serviceProvider, this, shortcut); + } + + /// + /// Register a freshly-built region on the cache. Called by + /// ; + /// mirrors cppcache CacheImpl::createRegion map insertion + /// (cppcache/src/CacheImpl.cpp:395-398, 440). + /// + internal void RegisterRegion(string name, IRegion region) + { + ObjectDisposedException.ThrowIf(IsClosed, this); + if (!_regions.TryAdd(name, region)) + { + throw new RegionExistsException( + $"CacheImpl::createRegion: \"{name}\" region exists in local cache"); + } + } + + /// + /// Shared typed-result adapter used to wrap freshly-created regions + /// in ; same instance the + /// cache hands to . + /// + internal Protocol.Serialization.TypedResultAdapter TypedResultAdapter => _typedResultAdapter; + + /// + /// Delegates to PoolManager.DefaultPool.QueryService (or the + /// named pool's). Mirrors cppcache CacheImpl::getQueryService() + /// pool-mode branch (CacheImpl.cpp:171-203); the non-pool + /// fallback in the same method has no .NET counterpart per memory + /// pool-only-no-non-pool.md. + /// + public IQueryService GetQueryService(string? poolName = null) + { + ObjectDisposedException.ThrowIf(IsClosed, this); + + // null / empty → DefaultPool. Aligns with PoolManager.Find's + // own empty-string convention, but null gets normalised here + // so PoolManager.Find (which throws on null) never sees it. + if (string.IsNullOrEmpty(poolName)) + { + var defaultPool = _poolManager.Value.DefaultPool + ?? throw new InvalidOperationException( + "Cache has no default pool — call EnsureInitializedAsync " + + "first or ensure at least one pool is registered."); + return defaultPool.QueryService; + } + + var pool = _poolManager.Value.Find(poolName) + ?? throw new ArgumentException( + $"Pool '{poolName}' is not registered.", nameof(poolName)); + return pool.QueryService; + } + + + + public bool IsClosed { get; private set; } + + public string Name => _name; + + public IPoolManager PoolManager => _poolManager.Value; + + + // public ITypeRegistry TypeRegistry { get; } = typeRegistry; + + // public bool PdxIgnoreUnreadFields => _options.Cache?.Pdx.IgnoreUnreadFields ?? false; + // public bool PdxReadSerialized => _options.Cache?.Pdx.ReadSerialized ?? false; + + + //#pragma warning disable CS0169, CS0414, CS0649 // placeholder fields mirroring CacheImpl; wired up phase by phase + + // // ── Lifecycle (CacheImpl.hpp:359-374) ── + // // m_closed → IsClosed property (already exposed) + // // m_initialized → captured by _initTask (null = not started) + // // m_initDoneLock → _initLock (SemaphoreSlim, async-friendly) + // // m_destroyCacheMutex → bucket 1, replaced by System.Threading.Lock + + // private bool _keepAlive; // m_keepAlive + + + + // // ── Connection / Pool (CacheImpl.hpp:330, 362-363, 369) ── + // private object? _distributedSystem; // m_distributedSystem + // // m_tcrConnectionManager / m_poolManager / m_clientProxyMembershipIDFactory + // // → fields above (DI / Cache-owned) + + // // ── Query (CacheImpl.hpp:370) ── + // // cppcache m_remoteQueryServicePtr is the non-pool fallback — + // // CacheImpl owns its own RemoteQueryService when no default pool + // // exists. We are pool-only (memory pool-only-no-non-pool.md), so + // // GetQueryService always delegates to PoolManager and never builds + // // a cache-owned service. The cppcache field has no .NET counterpart. + + // // ── Transactions (CacheImpl.hpp:376) ── + // private object? _cacheTransactionManager; // m_cacheTXManager + + // // ── PDX / serialization (CacheImpl.hpp:323-324, 379-383) ── + // private bool _pdxIgnoreUnreadFields; // m_ignorePdxUnreadFields + // private bool _pdxReadSerialized; // m_readPdxSerialized + // private object? _pdxTypeRegistry; // m_pdxTypeRegistry + // private object? _serializationRegistry;// m_serializationRegistry + + // // ── Versioning (CacheImpl.hpp:378) ── + // private object? _memberListForVersionStamp; // m_memberListForVersionStamp + + // // ── Partition-routing flags (CacheImpl.hpp:320-322) ── + // private int _networkHop; // m_networkhop (Interlocked 0/1) + // private int _prMetadataUpdated; // m_pr_metadata_updated (Interlocked 0/1) + // private int _serverGroupFlag; // m_serverGroupFlag (Interlocked int8_t) + + // // ── Auth (CacheImpl.hpp:382) ── + // private object? _authInitialize; // m_authInitialize + + //#pragma warning restore CS0169, CS0414, CS0649 + + +} diff --git a/src/Geode.Client/Internal/GeodeClientOptionsValidator.cs b/src/Geode.Client/Internal/GeodeClientOptionsValidator.cs new file mode 100644 index 0000000..15d1be1 --- /dev/null +++ b/src/Geode.Client/Internal/GeodeClientOptionsValidator.cs @@ -0,0 +1,53 @@ +using Geode.Client.Options; +using Microsoft.Extensions.Options; + +namespace Geode.Client.Internal; + +/// +/// for +/// . Wired into every +/// AddGeodeClient overload via ValidateOnStart() so a +/// misconfigured pool fails at host build time instead of leaking out +/// as a cryptic deep inside +/// Cache.InitializeCoreAsync. +/// +/// +/// +/// Thin wrapper — the actual rules live on each options class as +/// Validate(string prefix) methods (see +/// , recursing into +/// sub-options). This class's only job is to bridge the +/// contract: build a prefix +/// from the (possibly null) name and wrap the failure +/// into a . +/// +/// +/// The same logic is reused by +/// IGeodeCacheFactory.Create after running the caller-supplied +/// action on a cloned options instance — validation lives on +/// the options so both entry points share one truth. +/// +/// +/// Single instance handles all named registrations: this validator +/// returns the same verdict for any name (named, default, or +/// keyed). cppcache has no equivalent — its config layer is XML + +/// runtime checks scattered across CacheImpl::create; we +/// hoist the checks up into one DI-time gate. +/// +/// +internal sealed class GeodeClientOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, GeodeClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var prefix = string.IsNullOrEmpty(name) + ? nameof(GeodeClientOptions) + : $"{nameof(GeodeClientOptions)}[{name}]"; + + var failures = options.Validate(prefix).ToList(); + return failures.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(failures); + } +} diff --git a/src/Geode.Client/Internal/LocalRegion.cs b/src/Geode.Client/Internal/LocalRegion.cs new file mode 100644 index 0000000..882919e --- /dev/null +++ b/src/Geode.Client/Internal/LocalRegion.cs @@ -0,0 +1,67 @@ +namespace Geode.Client.Internal; + +/// +/// Abstract local-only region machinery. Mirrors cppcache +/// LocalRegion (cppcache/src/LocalRegion.hpp:119) — owns +/// the in-memory entry map (m_entries), name / full-path, +/// listener / writer / loader hooks, persistence manager, expiry +/// task plumbing. +/// +/// +/// +/// Phase 1.x is proxy-only (no client-side caching), so the in-memory +/// map + callback machinery is all deferred. The class still exists in +/// the hierarchy so ThinClientRegion sits at the same depth as +/// cppcache; once caching-enabled is honoured (Phase 2+), the +/// local-cache code lands here without disturbing the derived class. +/// +/// +/// cppcache ctor signature: (name, CacheImpl*, parentRegion, +/// RegionAttributes, CacheStatistics, enableTimeStatistics). We +/// keep name + parent + attributes; cache back-ref, +/// stats, and time-stats flag are deferred until something actually +/// reads them. +/// +/// +internal abstract class LocalRegion : RegionInternal +{ + protected LocalRegion( + string name, + RegionInternal? parent, + RegionAttributes attributes) + : base(attributes) + { + ArgumentException.ThrowIfNullOrEmpty(name); + Name = name; + Parent = parent; + + // cppcache LocalRegion.cpp:75-79 — root: "/" + name; + // sub: parent.FullPath + "/" + name. + FullPath = parent is null + ? "/" + name + : parent.FullPath + "/" + name; + } + + /// + /// Parent region in the sub-region tree, or for a + /// root region. Mirrors cppcache LocalRegion::m_parentRegion. + /// + protected RegionInternal? Parent { get; } + + public override string Name { get; } + public override string FullPath { get; } + + // All 11 IRegion ops still abstract — concrete dispatch lives in + // ThinClientRegion (Phase 1.x). When local caching lands (Phase 2+), + // base impls go here that consult m_entries first and delegate to + // the derived class for server roundtrips. + + // TODO future phases — fields cppcache LocalRegion holds that we'll + // grow into: + // m_entries (EntriesMap) — Phase 2+ (caching-enabled) + // m_listener / m_writer / m_loader — niche, may stay cut + // m_persistenceManager — not implemented (CLAUDE.md) + // expiry_task_id_ — server-side, not client + // m_destroyPending — Phase 1.5 lifecycle + // m_attachedPool — Phase 1.x wiring (Step B+) +} diff --git a/src/Geode.Client/Internal/LocatorConnection.cs b/src/Geode.Client/Internal/LocatorConnection.cs new file mode 100644 index 0000000..9f976fb --- /dev/null +++ b/src/Geode.Client/Internal/LocatorConnection.cs @@ -0,0 +1,124 @@ +using System.Net.Sockets; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// One short-lived TCP connection to a locator endpoint. Mirrors +/// cppcache TcpConn instantiated by +/// ThinClientLocatorHelper::createConnection +/// (cppcache/src/ThinClientLocatorHelper.cpp:87-115): open +/// socket, write one request, read one response, close. +/// +/// +/// +/// Distinct from — +/// no Geode handshake, no 17-byte framed header, no transaction id, +/// no connection pool. Locator protocol is one request per connection; +/// dispose between requests. Nagle is disabled (same as cppcache) so +/// the one-shot send hits the wire immediately. +/// +/// +/// Step C lands only plain TCP (TcpConn equivalent). The cppcache +/// TcpSslConn branch is Phase 3 TLS work; SNI is the same phase. +/// +/// +internal sealed class LocatorConnection(ILogger logger) : IAsyncDisposable +{ + private readonly TcpClient _tcpClient = new() { NoDelay = true }; + private NetworkStream? _stream; + private int _disposed; + + /// Open the socket to :. Caller sets connect timeout via . + public async Task ConnectAsync(string host, int port, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(host); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + + await _tcpClient.ConnectAsync(host, port, ct).ConfigureAwait(false); + _stream = _tcpClient.GetStream(); + logger.LogDebug("LocatorConnection connected to {Host}:{Port}", host, port); + } + + /// Write in full and flush. Caller has already produced the bytes (gossip version + Geode version + DSCode-tagged FixedId envelope + request body). + public async Task SendAsync(ReadOnlyMemory data, CancellationToken ct = default) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must be called before {nameof(SendAsync)}."); + + logger.LogTrace("LocatorConnection sending {ByteCount} bytes", data.Length); + await stream.WriteAsync(data, ct).ConfigureAwait(false); + await stream.FlushAsync(ct).ConfigureAwait(false); + } + + /// Block until is filled or the peer closes. + /// Peer closed before the buffer was filled. + public async Task ReadExactlyAsync(Memory buffer, CancellationToken ct = default) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must be called before {nameof(ReadExactlyAsync)}."); + + await stream.ReadExactlyAsync(buffer, ct).ConfigureAwait(false); + } + + /// Read whatever bytes are currently available, up to .Length. Returns 0 on peer close (EOF). + public async Task ReadAsync(Memory buffer, CancellationToken ct = default) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must be called before {nameof(ReadAsync)}."); + + return await stream.ReadAsync(buffer, ct).ConfigureAwait(false); + } + + /// + /// Close the socket cleanly. Mirrors cppcache TcpConn + /// destructor: flush pending writes, send a TCP FIN + /// (SocketShutdown.Both) so the peer sees a graceful close + /// instead of an RST, then release the OS handles. Idempotent. + /// + public async Task CloseAsync(CancellationToken ct = default) + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + + var stream = _stream; + _stream = null; + + if (stream is not null) + { + // Step 1: flush anything buffered locally. Swallow errors — + // socket may already be dead and we still want to proceed + // with the rest of the teardown. + try + { + await stream.FlushAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogTrace(ex, "LocatorConnection: flush before close failed (benign)"); + } + + // Step 2: half-close both directions. Sends a FIN so the + // peer's read loop returns 0 cleanly; without this the + // underlying TcpClient.Dispose can leave the socket in + // TIME_WAIT with RST behaviour on some stacks. + try + { + _tcpClient.Client.Shutdown(SocketShutdown.Both); + } + catch (Exception ex) + { + logger.LogTrace(ex, "LocatorConnection: socket shutdown failed (benign — socket likely already closed)"); + } + + stream.Dispose(); + } + + _tcpClient.Dispose(); + logger.LogTrace("LocatorConnection closed"); + } + + public ValueTask DisposeAsync() => new(CloseAsync()); +} diff --git a/src/Geode.Client/Internal/LocatorListRequest.cs b/src/Geode.Client/Internal/LocatorListRequest.cs new file mode 100644 index 0000000..606ba4e --- /dev/null +++ b/src/Geode.Client/Internal/LocatorListRequest.cs @@ -0,0 +1,32 @@ +using Geode.Client.Protocol; + +namespace Geode.Client.Internal; + +/// +/// Locator wire request: "give me the current locator set, filtered +/// by server group". Mirrors cppcache LocatorListRequest +/// (cppcache/src/LocatorListRequest.hpp/.cpp). +/// +/// +/// Wire tag: . Body is one +/// Java-modified-UTF-8 length-prefixed string (the server group; +/// empty selects all servers, matching cppcache default). The outer +/// locator frame (gossip version + Geode version + DSCode-tagged +/// FixedId envelope) is the LocatorConnection's responsibility +/// (Step C). cppcache's fromData is intentionally empty ??/// locator requests are client-to-server only, never deserialized on +/// the receiving side. +/// +internal sealed record LocatorListRequest(string ServerGroup = "") +{ + /// + /// Write the request body. Mirrors cppcache + /// LocatorListRequest::toData + /// (LocatorListRequest.cpp:32-34): a single + /// writeString(m_servergroup). + /// + public void WriteTo(DataOutput writer) + { + ArgumentNullException.ThrowIfNull(writer); + writer.WriteString(ServerGroup); + } +} diff --git a/src/Geode.Client/Internal/LocatorListResponse.cs b/src/Geode.Client/Internal/LocatorListResponse.cs new file mode 100644 index 0000000..05c2110 --- /dev/null +++ b/src/Geode.Client/Internal/LocatorListResponse.cs @@ -0,0 +1,60 @@ +using Geode.Client.Protocol; + +namespace Geode.Client.Internal; + +/// +/// Locator wire response: the cluster's current authoritative locator +/// set, plus a hint flag. Mirrors cppcache LocatorListResponse +/// (cppcache/src/LocatorListResponse.hpp/.cpp). +/// +/// +/// Wire tag: . Two fields: +/// (the new locator list — replaces the +/// client's working set after a merge step in Step D) and +/// (a server-side load-balancer hint, +/// currently unused on the client). Wire body is u32 count +/// followed by that many (host, port) pairs, then a bool. +/// The outer frame (gossip / Geode version / DSCode envelope) is the +/// LocatorConnection's job (Step C). +/// +internal sealed record LocatorListResponse( + IReadOnlyList Locators, + bool IsBalanced) +{ + /// + /// Decode the response body. Mirrors cppcache + /// LocatorListResponse::fromData + /// (LocatorListResponse.cpp:30-33) + readList + /// (LocatorListResponse.cpp:39-46): u32 count followed + /// by count ServerLocation pairs, then a bool. + /// Each ServerLocation reads readString(host) + + /// readInt32(port) per cppcache ServerLocation::fromData + /// (ServerLocation.hpp:73-77). + /// + public static LocatorListResponse ReadFrom(DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + + var count = reader.ReadInt32(); + if (count < 0) + { + throw new GeodeException( + $"LocatorListResponse: negative locator count {count} — wire corruption."); + } + + var locators = new List(count); + for (var i = 0; i < count; i++) + { + // cppcache ServerLocation.fromData: readString + readInt32. + // Host is never legitimately null on the wire; coerce a stray + // CacheableNullString DSCode to empty rather than crashing the + // decode mid-list. + var host = reader.ReadString() ?? string.Empty; + var port = reader.ReadInt32(); + locators.Add(new ServerLocation(host, port)); + } + + var isBalanced = reader.ReadBool(); + return new LocatorListResponse(locators, isBalanced); + } +} diff --git a/src/Geode.Client/Internal/PoolAttributes.cs b/src/Geode.Client/Internal/PoolAttributes.cs new file mode 100644 index 0000000..e703b31 --- /dev/null +++ b/src/Geode.Client/Internal/PoolAttributes.cs @@ -0,0 +1,199 @@ +namespace Geode.Client.Internal; + +/// +/// Pool configuration bag held by . Snapshotted via +/// at time and handed to +/// the built pool, so further factory mutations don't affect already-built pools. +/// +/// +/// Mirrors cppcache PoolAttributes (cppcache/src/PoolAttributes.hpp) +/// 1:1. Defaults match PoolFactory::DEFAULT_* constants in +/// cppcache/include/geode/PoolFactory.hpp + cppcache/src/PoolFactory.cpp. +/// +internal sealed class PoolAttributes +{ + // PoolFactory.cpp:35-36 — std::chrono::seconds{10} + public TimeSpan FreeConnectionTimeout { get; set; } = TimeSpan.FromSeconds(10); + + // PoolFactory.cpp:38-39 — std::chrono::minutes{5} + public TimeSpan LoadConditioningInterval { get; set; } = TimeSpan.FromMinutes(5); + + // PoolFactory.hpp:90 — DEFAULT_SOCKET_BUFFER_SIZE = 32768 + public int SocketBufferSize { get; set; } = 32768; + + // PoolFactory.cpp:41-42 — std::chrono::seconds{10} + public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromSeconds(10); + + // PoolFactory.hpp:102 — DEFAULT_MIN_CONNECTIONS = 1 + public int MinConnections { get; set; } = 1; + + // PoolFactory.hpp:108 — DEFAULT_MAX_CONNECTIONS = -1 (unbounded) + public int MaxConnections { get; set; } = -1; + + // PoolFactory.cpp:44-45 — std::chrono::seconds{5} + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(5); + + // PoolFactory.hpp:121 — DEFAULT_RETRY_ATTEMPTS = -1 (pool decides) + public int RetryAttempts { get; set; } = -1; + + // Per-pool override; null falls back to SystemProperties.PingInterval + // (cppcache PoolFactory.cpp:47-48 keeps 10s baked into PoolAttributes; + // we lift the default to SystemProperties so cache-wide config can win + // when no pool-level value is set). + public TimeSpan? PingInterval { get; set; } + + // PoolFactory.cpp:50-51 — std::chrono::seconds{5} + public TimeSpan UpdateLocatorListInterval { get; set; } = TimeSpan.FromSeconds(5); + + // PoolFactory.cpp:53-54 — milliseconds::zero() (disabled) + public TimeSpan StatisticInterval { get; set; } = TimeSpan.Zero; + + // PoolFactory.hpp:146 — DEFAULT_SUBSCRIPTION_ENABLED = false + public bool SubscriptionEnabled { get; set; } + + // PoolFactory.hpp:154 — DEFAULT_SUBSCRIPTION_REDUNDANCY = 0 + public int SubscriptionRedundancy { get; set; } + + // PoolFactory.cpp:56-58 — std::chrono::seconds{900} + public TimeSpan SubscriptionMessageTrackingTimeout { get; set; } = TimeSpan.FromSeconds(900); + + // PoolFactory.cpp:60-61 — std::chrono::seconds{100} + public TimeSpan SubscriptionAckInterval { get; set; } = TimeSpan.FromSeconds(100); + + // PoolFactory.hpp:180 — DEFAULT_THREAD_LOCAL_CONN = false + public bool ThreadLocalConnection { get; set; } + + // PoolFactory.hpp:186 — DEFAULT_MULTIUSER_SECURE_MODE = false + public bool MultiuserSecureMode { get; set; } + + // PoolFactory.hpp:192 — DEFAULT_PR_SINGLE_HOP_ENABLED = true + public bool PrSingleHopEnabled { get; set; } = true; + + // PoolFactory.cpp:63 — DEFAULT_SERVER_GROUP = "" + public string ServerGroup { get; set; } = string.Empty; + + // PoolAttributes.cpp:48 — m_sniProxyPort(0); m_sniProxyHost default empty + public string SniProxyHost { get; set; } = string.Empty; + public int SniProxyPort { get; set; } + + public List Locators { get; } = []; + public List Servers { get; } = []; + + /// + /// Append a locator. Mirrors cppcache PoolAttributes::addLocator + /// (PoolAttributes.cpp:71-77): a pool has locators OR servers, not both. + /// + public void AddLocator(string host, int port) + { + if (Servers.Count > 0) + { + throw new ArgumentException("Cannot add both locators and servers to a pool"); + } + Locators.Add(new HostPort(host, port)); + } + + /// + /// Append a server. Mirrors cppcache PoolAttributes::addServer + /// (PoolAttributes.cpp:79-85): a pool has locators OR servers, not both. + /// + public void AddServer(string host, int port) + { + if (Locators.Count > 0) + { + throw new ArgumentException("Cannot add both locators and servers to a pool"); + } + Servers.Add(new HostPort(host, port)); + } + + /// + /// Validate. Same rule set as the prior CachePoolOptions.Validate + /// (minus the Name check — name is 's + /// concern, not part of the attrs). Adjusted for cppcache integer + /// sentinels: = -1 means unbounded, + /// = -1 means pool decides. + /// + public IEnumerable Validate(string prefix) + { + if (Locators.Count + Servers.Count == 0) + yield return $"{prefix} must have at least one locator or server."; + + // cppcache permits 0 (pure lazy). + if (MinConnections < 0) + yield return $"{prefix}.{nameof(MinConnections)} must be >= 0 (got {MinConnections})."; + + // -1 = unbounded; skip comparison. + if (MaxConnections != -1 && MaxConnections < MinConnections) + yield return $"{prefix}.{nameof(MaxConnections)} ({MaxConnections}) must be >= {nameof(MinConnections)} ({MinConnections})."; + + // cppcache PoolFactory::setUpdateLocatorListInterval (PoolFactory.cpp): + // negative rejected; 0 = disable refresh loop. + if (UpdateLocatorListInterval < TimeSpan.Zero) + yield return $"{prefix}.{nameof(UpdateLocatorListInterval)} must be >= 0 (got {UpdateLocatorListInterval})."; + + // cppcache PoolFactory::setLoadConditioningInterval: negative rejected; + // 0 = disable load conditioning. + if (LoadConditioningInterval < TimeSpan.Zero) + yield return $"{prefix}.{nameof(LoadConditioningInterval)} must be >= 0 (got {LoadConditioningInterval})."; + + // cppcache PoolFactory::setIdleTimeout: negative rejected; + // 0 = disable idle-driven shrink. + if (IdleTimeout < TimeSpan.Zero) + yield return $"{prefix}.{nameof(IdleTimeout)} must be >= 0 (got {IdleTimeout})."; + + // -1 = pool decides (cppcache DEFAULT_RETRY_ATTEMPTS). + if (RetryAttempts < -1) + yield return $"{prefix}.{nameof(RetryAttempts)} must be >= -1 (got {RetryAttempts})."; + + for (var i = 0; i < Locators.Count; i++) + { + var l = Locators[i]; + if (string.IsNullOrWhiteSpace(l.Host)) + yield return $"{prefix}.Locators[{i}].Host must not be null, empty, or whitespace."; + if (l.Port is < 1 or > 65535) + yield return $"{prefix}.Locators[{i}].Port must be in the range [1, 65535] (got {l.Port})."; + } + + for (var i = 0; i < Servers.Count; i++) + { + var s = Servers[i]; + if (string.IsNullOrWhiteSpace(s.Host)) + yield return $"{prefix}.Servers[{i}].Host must not be null, empty, or whitespace."; + if (s.Port is < 1 or > 65535) + yield return $"{prefix}.Servers[{i}].Port must be in the range [1, 65535] (got {s.Port})."; + } + } + + /// Deep clone; snapshot for . + public PoolAttributes Clone() + { + var c = new PoolAttributes + { + FreeConnectionTimeout = FreeConnectionTimeout, + LoadConditioningInterval = LoadConditioningInterval, + SocketBufferSize = SocketBufferSize, + ReadTimeout = ReadTimeout, + MinConnections = MinConnections, + MaxConnections = MaxConnections, + IdleTimeout = IdleTimeout, + RetryAttempts = RetryAttempts, + PingInterval = PingInterval, + UpdateLocatorListInterval = UpdateLocatorListInterval, + StatisticInterval = StatisticInterval, + SubscriptionEnabled = SubscriptionEnabled, + SubscriptionRedundancy = SubscriptionRedundancy, + SubscriptionMessageTrackingTimeout = SubscriptionMessageTrackingTimeout, + SubscriptionAckInterval = SubscriptionAckInterval, + ThreadLocalConnection = ThreadLocalConnection, + MultiuserSecureMode = MultiuserSecureMode, + PrSingleHopEnabled = PrSingleHopEnabled, + ServerGroup = ServerGroup, + SniProxyHost = SniProxyHost, + SniProxyPort = SniProxyPort, + }; + c.Locators.AddRange(Locators); + c.Servers.AddRange(Servers); + return c; + } +} + +internal readonly record struct HostPort(string Host, int Port); diff --git a/src/Geode.Client/Internal/PoolManager.cs b/src/Geode.Client/Internal/PoolManager.cs new file mode 100644 index 0000000..2cd20a4 --- /dev/null +++ b/src/Geode.Client/Internal/PoolManager.cs @@ -0,0 +1,152 @@ +using System.Collections.Concurrent; +using Geode.Client.Internal; + +namespace Geode.Client.Internal; + +/// +/// Registry and lifecycle owner for named connection pools. Mirrors +/// cppcache PoolManagerImpl +/// (cppcache/src/PoolManagerImpl.hpp/.cpp); the public clicache +/// surface PoolManager +/// (cppcache/include/geode/PoolManager.hpp) is collapsed into +/// this single internal class — .NET doesn't need the Pimpl +/// shim, and there is no MVP consumer use case that warrants exposing +/// the registry as a public interface. +/// +/// +/// +/// Threading: ConcurrentDictionary covers the registry; the +/// "first-added wins as default" rule is enforced via +/// . cppcache's +/// recursive_mutex (m_connectionPoolsLock) is replaced by +/// these primitives. +/// +/// +/// Holds a back-pointer to the owning (exposed +/// via ) so scope-internal services routed through +/// the pool manager can reach per-cache state (options, system +/// properties, etc.) — mirrors cppcache +/// connManager->getCacheImpl()'s role. +/// +/// +internal sealed class PoolManager(IServiceProvider serviceProvider, GeodeCache cache) + + : IPoolManager, IAsyncDisposable +{ + + private IPool? _defaultPool; + private int _disposed; + private readonly ConcurrentDictionary _pools = new(StringComparer.Ordinal); + + /// + /// Register a pool under . The first + /// successful registration also becomes . + /// Mirrors cppcache PoolManagerImpl::addPool. + /// + /// + /// Thrown when a pool with the same name is already registered. + /// + internal void AddPool(string name, ThinClientPoolDM pool) + { + if (!_pools.TryAdd(name, pool)) + { + throw new InvalidOperationException( + $"Pool '{name}' is already registered."); + } + Interlocked.CompareExchange(ref _defaultPool, pool, null); + } + + /// + /// Deregister a pool. Mirrors cppcache + /// PoolManagerImpl::removePool. Does not dispose the pool + /// itself — caller owns disposal lifecycle. Returns + /// true when the name existed. + /// + internal bool RemovePool(string name) + { + return _pools.TryRemove(name, out _); + } + + /// + /// Back-pointer to the owning cache; lets pool-side code reach per-cache + /// state (options, system properties). Mirrors cppcache + /// connManager->getCacheImpl(). + /// + internal GeodeCache Cache => cache; + + /// + /// Close every registered pool. Mirrors cppcache + /// PoolManagerImpl::close(keepAlive); routes + /// into each pool's + /// DestroyAsync. + /// + /// + /// Idempotent. After this returns the manager rejects further + /// calls (the disposed flag stays set). + /// + public async Task CloseAsync(bool keepAlive = false, CancellationToken ct = default) + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + + // Snapshot the values, then clear, before awaiting destroy — + // late AddPool callers will see _disposed == 1 and throw. + var pools = _pools.Values.ToArray(); + _pools.Clear(); + Volatile.Write(ref _defaultPool, null); + + // Aggregate failures the same way Task.WhenAll does; we don't + // want one slow / faulty pool to mask the rest. + await Task.WhenAll(pools.Select(p => p.DestroyAsync(keepAlive, ct))) + .ConfigureAwait(false); + } + + /// + /// New bound to this manager. Mirrors cppcache + /// PoolManager::createFactory(). + /// + public PoolFactory CreateFactory() + { + return new PoolFactory(serviceProvider, this); + } + + /// Delegates to with keepAlive: false. + public ValueTask DisposeAsync() => new(CloseAsync(keepAlive: false)); + + /// + /// Look up a pool by name. An empty + /// returns , matching cppcache + /// PoolManagerImpl::find(name). + /// + public IPool? Find(string? name = null) + { + if (name is null) return DefaultPool; + return _pools.TryGetValue(name, out var pool) ? pool : null; + } + + /// + /// Look up the pool a region was created on. Mirrors cppcache + /// PoolManagerImpl::find(region) → + /// find(region->getAttributes().getPoolName()). + /// + public IPool? Find(IRegion region) + { + return Find(region.PoolName); + } + + /// + /// Snapshot of the registry. Mirrors cppcache + /// PoolManagerImpl::getAll(). + /// + public IReadOnlyDictionary GetAll() => + _pools.ToDictionary(kv => kv.Key, kv => (IPool)kv.Value); + + /// + /// First pool registered via . Mirrors + /// cppcache m_defaultPool: the manager picks an arbitrary + /// "default" so callers that look up by empty name still get + /// something back. + /// + public IPool? DefaultPool => Volatile.Read(ref _defaultPool); + +} + diff --git a/src/Geode.Client/Internal/PoolStatistics.cs b/src/Geode.Client/Internal/PoolStatistics.cs new file mode 100644 index 0000000..4ad011e --- /dev/null +++ b/src/Geode.Client/Internal/PoolStatistics.cs @@ -0,0 +1,515 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Geode.Client.Internal; + +/// +/// Pool-scoped Meter + ActivitySource sink. Mirrors cppcache +/// PoolStats (PoolStatistics.{cpp,hpp}) — emits per-pool +/// counters / histograms / gauges via +/// so OTel / Prometheus exporters +/// scrape without touching every PoolDM modify site. +/// +/// +/// cppcache PoolStats 27-field catalogue +/// (PoolStatistics.cpp:34-122): gauge × 6, counter × 15, +/// time/bytes × 6. Ported so far: PoolConnects / PoolDisconnects +/// / MinPoolSizeConnects / LoadConditioningConnects / +/// LoadConditioningDisconnects / IdleDisconnects / +/// ClientOpFailures / ClientOpTimeouts / +/// ReceivedBytes Counters / PoolConnections / +/// Locators / Servers / ConnectionWaitsInProgress / +/// ClientOpsInProgress gauges / LocatorListRequestTime / +/// ClientConnectionRequestTime / ConnectionWaitTime / +/// ClientOpTime Histograms (the last four each merge cppcache's +/// request+response or counter+timer halves into one Histogram — +/// .Count subsumes the integer counter). Non-cppcache additions: +/// ConnectedServers gauge (surfaces cppcache's internal +/// connected_endpoints_ atomic); PingSweepTime / +/// EndpointPingTime (our own ping-loop liveness signals — +/// cppcache PoolStats has no ping counters). +/// +internal class PoolStatistics(string poolName) +{ + private static readonly string _assemblyVersion = + typeof(PoolStatistics).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + /// Shared Meter for all pool-scoped instruments. + readonly static Meter _meter = new("Geode.Client.Pool", _assemblyVersion); + + /// + /// Elapsed time of LocatorListRequest RPCs issued by the + /// background locator-list refresh loop + /// ('s UpdateLocatorsLocalAsync; + /// wire DSFid -54 / -51). + /// + readonly static Histogram _locatorListRequestTime = _meter.CreateHistogram( + "LocatorListRequestTime", + unit: "s", + description: "Elapsed time of LocatorListRequest RPCs issued by the pool's background locator-list refresh loop."); + + /// + /// Elapsed time of ClientConnectionRequest RPCs (on-demand + /// endpoint selection via locator; wire DSFid -53 / -50). cppcache + /// incLoctorRequests / incLoctorResposes + /// (PoolStatistics.cpp:43-50) counted request + response halves + /// separately; merged here into one Histogram (.Count subsumes + /// both). + /// + readonly static Histogram _clientConnectionRequestTime = _meter.CreateHistogram( + "ClientConnectionRequestTime", + unit: "s", + description: "Elapsed time of ClientConnectionRequest RPCs issued by the pool when opening a new server connection through a locator."); + + /// Record one sample. + public void LocatorListRequest(TimeSpan elapsed) => + _locatorListRequestTime.Record(elapsed.TotalSeconds, new KeyValuePair("poolName", poolName)); + + /// Record one sample. + public void ClientConnectionRequest(TimeSpan elapsed) => + _clientConnectionRequestTime.Record(elapsed.TotalSeconds, new KeyValuePair("poolName", poolName)); + + /// + /// Total connections opened by the pool, all causes combined. Mirrors + /// cppcache connects (PoolStatistics.cpp:53-58). + /// Combined with : connects - disconnects ≈ PoolConnections + /// at steady state; rates give churn / shrink velocity. + /// + readonly static Counter _poolConnects = _meter.CreateCounter( + "PoolConnects", + unit: "connections", + description: "Total connections opened by the pool over its lifetime, all causes combined."); + + /// + /// Total connections closed by the pool, all causes combined. Pair + /// with . Mirrors cppcache disconnects + /// (PoolStatistics.cpp:53-58). + /// + readonly static Counter _poolDisconnects = _meter.CreateCounter( + "PoolDisconnects", + unit: "connections", + description: "Total connections closed by the pool over its lifetime, all causes combined."); + + /// Bump . + public void PoolConnect() => + _poolConnects.Add(1, new KeyValuePair("poolName", poolName)); + + /// Bump . + public void PoolDisconnect() => + _poolDisconnects.Add(1, new KeyValuePair("poolName", poolName)); + + /// + /// Load conditioning — periodic forced rotation of long-lived conns + /// (CleanStaleConnectionsAsync replace path). Mirrors cppcache + /// loadConditioningConnects (PoolStatistics.cpp:63-73). + /// + readonly static Counter _loadConditioningConnects = _meter.CreateCounter( + "LoadConditioningConnects", + unit: "connections", + description: "Total connections opened to replace load-conditioning-expired conns."); + + /// + /// Pair with . Mirrors cppcache + /// loadConditioningDisconnects (PoolStatistics.cpp:63-73). + /// + readonly static Counter _loadConditioningDisconnects = _meter.CreateCounter( + "LoadConditioningDisconnects", + unit: "connections", + description: "Total connections closed because they hit the load-conditioning expiry threshold."); + + /// Bump . + public void LoadConditioningConnect() => + _loadConditioningConnects.Add(1, new KeyValuePair("poolName", poolName)); + + /// Bump . + public void LoadConditioningDisconnect() => + _loadConditioningDisconnects.Add(1, new KeyValuePair("poolName", poolName)); + + /// + /// Min-pool-size restore — conn opened by the conn-management loop + /// (RestoreMinConnectionsAsync) to bring _poolSize back + /// up to . Mirrors + /// cppcache minPoolSizeConnects (PoolStatistics.cpp:59-62). + /// + readonly static Counter _minPoolSizeConnects = _meter.CreateCounter( + "MinPoolSizeConnects", + unit: "connections", + description: "Total connections opened by the conn-management loop to maintain MinConnections."); + + /// Bump . + public void MinPoolSizeConnect() => + _minPoolSizeConnects.Add(1, new KeyValuePair("poolName", poolName)); + + /// + /// Idle shrink — conn unused beyond + /// while + /// _poolSize > MinConnections, closed without replacement + /// (CleanStaleConnectionsAsync pure-shrink path). Mirrors + /// cppcache idleDisconnects (PoolStatistics.cpp:66-69). + /// + readonly static Counter _idleDisconnects = _meter.CreateCounter( + "IdleDisconnects", + unit: "connections", + description: "Total connections closed because they sat idle beyond the IdleTimeout while the pool was above MinConnections."); + + /// Bump . + public void IdleDisconnect() => + _idleDisconnects.Add(1, new KeyValuePair("poolName", poolName)); + + /// + /// Elapsed time of one PingServerLocalAsync sweep. No cppcache + /// equivalent — our own design for "is the ping loop alive". + /// .Count subsumes the old PingTicks counter; recorded in + /// finally so exception paths still tick. + /// + readonly static Histogram _pingSweepTime = _meter.CreateHistogram( + "PingSweepTime", + unit: "s", + description: "Elapsed time of one ping-loop sweep over the pool's connected endpoints."); + + /// + /// Elapsed time of one endpoint.PingAsync that returned without + /// throwing AND left the endpoint still connected. .Count + /// subsumes the old PingSuccesses counter. Pair with + /// . + /// + readonly static Histogram _endpointPingTime = _meter.CreateHistogram( + "EndpointPingTime", + unit: "s", + description: "Elapsed time of one successful endpoint ping (returned without throwing and left the endpoint still connected)."); + + /// Record one sample. + public void PingSweep(TimeSpan elapsed) => + _pingSweepTime.Record(elapsed.TotalSeconds, new KeyValuePair("poolName", poolName)); + + /// Record one sample. + public void EndpointPing(TimeSpan elapsed) => + _endpointPingTime.Record(elapsed.TotalSeconds, new KeyValuePair("poolName", poolName)); + + /// + /// Per-pool reader registry for the + /// pull-mode gauge, keyed by poolName. cppcache uses push + /// (setCurPoolConnections on every modify); .NET idiomatic pull + /// lets the listener decide cadence and avoids missing a modify site. + /// + private static readonly ConcurrentDictionary> _poolConnectionsReaders = new(); + + /// + /// Current number of connections held by the pool. Mirrors cppcache + /// poolConnections IntGauge (PoolStatistics.cpp:51-52, + /// m_poolSize). + /// + readonly static ObservableGauge _poolConnections = _meter.CreateObservableGauge( + "PoolConnections", + observeValues: ObservePoolConnections, + unit: "connections", + description: "Current number of connections held by the pool. Mirrors cppcache `poolConnections` IntGauge (m_poolSize)."); + + private static IEnumerable> ObservePoolConnections() + { + foreach (var (name, reader) in _poolConnectionsReaders) + { + yield return new Measurement(reader(), new KeyValuePair("poolName", name)); + } + } + + /// Register this pool's reader for the PoolConnections gauge. + public void SetPoolConnectionsReader(Func reader) => + _poolConnectionsReaders[poolName] = reader; + + /// Drop this pool's reader from the gauge registry. + public void ClearPoolConnectionsReader() => + _poolConnectionsReaders.TryRemove(poolName, out _); + + /// + /// Per-pool reader registry for the pull-mode + /// gauge. cppcache setLocators push site is single (after + /// successful getEndpointForNewFwdConn, ThinClientPoolDM.cpp:596); + /// pull-mode covers both the on-demand path and the background + /// UpdateLocatorsLocalAsync swap without instrumenting either. + /// + private static readonly ConcurrentDictionary> _locatorsReaders = new(); + + /// + /// Current number of locators known to the pool's + /// . Mirrors cppcache + /// locators IntGauge (PoolStatistics.cpp:36-37). + /// + readonly static ObservableGauge _locators = _meter.CreateObservableGauge( + "Locators", + observeValues: ObserveLocators, + unit: "locators", + description: "Current number of locators known to the pool. Mirrors cppcache `locators` IntGauge."); + + private static IEnumerable> ObserveLocators() + { + foreach (var (name, reader) in _locatorsReaders) + { + yield return new Measurement(reader(), new KeyValuePair("poolName", name)); + } + } + + /// Register this pool's reader for the Locators gauge. + public void SetLocatorsReader(Func reader) => + _locatorsReaders[poolName] = reader; + + /// Drop this pool's reader from the gauge registry. + public void ClearLocatorsReader() => + _locatorsReaders.TryRemove(poolName, out _); + + /// + /// Per-pool reader registry for the pull-mode + /// gauge. cppcache setServers only fires on addEP + /// (ThinClientPoolDM.cpp:2019) — never decrements — making it a + /// monotonic high-water mark in cppcache; pull-mode lets our reader + /// reflect endpoint removal whenever that lands. + /// + private static readonly ConcurrentDictionary> _serversReaders = new(); + + /// + /// Current number of endpoints the pool is aware of. Mirrors cppcache + /// servers IntGauge (PoolStatistics.cpp:38-39). + /// + readonly static ObservableGauge _servers = _meter.CreateObservableGauge( + "Servers", + observeValues: ObserveServers, + unit: "servers", + description: "Current number of endpoints the pool is aware of. Mirrors cppcache `servers` IntGauge."); + + private static IEnumerable> ObserveServers() + { + foreach (var (name, reader) in _serversReaders) + { + yield return new Measurement(reader(), new KeyValuePair("poolName", name)); + } + } + + /// Register this pool's reader for the Servers gauge. + public void SetServersReader(Func reader) => + _serversReaders[poolName] = reader; + + /// Drop this pool's reader from the gauge registry. + public void ClearServersReader() => + _serversReaders.TryRemove(poolName, out _); + + /// + /// Per-pool reader registry for the + /// pull-mode gauge. cppcache pushes via incConnectedEndpoints / + /// decConnectedEndpoints bumping the + /// connected_endpoints_ atomic; pull-mode reads the live + /// counter on each listener tick. + /// + private static readonly ConcurrentDictionary> _connectedServersReaders = new(); + + /// + /// Current number of endpoints whose IsConnected bit is true + /// (i.e. healthy, ping-passing). No direct cppcache catalogue entry — + /// surfaces the connected_endpoints_ atomic + /// (ThinClientPoolDM.cpp:2055-2068) that cppcache keeps as + /// internal state for the PDX-registry-clear trigger. + /// + readonly static ObservableGauge _connectedServers = _meter.CreateObservableGauge( + "ConnectedServers", + observeValues: ObserveConnectedServers, + unit: "servers", + description: "Current number of endpoints whose IsConnected bit is true. Surfaces cppcache `connected_endpoints_` atomic."); + + private static IEnumerable> ObserveConnectedServers() + { + foreach (var (name, reader) in _connectedServersReaders) + { + yield return new Measurement(reader(), new KeyValuePair("poolName", name)); + } + } + + /// Register this pool's reader for the ConnectedServers gauge. + public void SetConnectedServersReader(Func reader) => + _connectedServersReaders[poolName] = reader; + + /// Drop this pool's reader from the gauge registry. + public void ClearConnectedServersReader() => + _connectedServersReaders.TryRemove(poolName, out _); + + /// + /// Per-pool reader registry for the + /// pull-mode gauge. cppcache bumps via incCurWaitingConnections / + /// decCurWaitingConnections around getConnectionFromQueue + /// (ThinClientPoolDM.cpp:1819, 1835); pull-mode reads the live + /// counter on each listener tick. + /// + private static readonly ConcurrentDictionary> _connectionWaitsInProgressReaders = new(); + + /// + /// Current number of threads waiting on the pool-wide MaxConnections + /// cap. Mirrors cppcache connectionWaitsInProgress IntGauge + /// (PoolStatistics.cpp:74-76). + /// + readonly static ObservableGauge _connectionWaitsInProgress = _meter.CreateObservableGauge( + "ConnectionWaitsInProgress", + observeValues: ObserveConnectionWaitsInProgress, + unit: "threads", + description: "Current number of threads waiting on the pool-wide MaxConnections cap. Mirrors cppcache `connectionWaitsInProgress` IntGauge."); + + private static IEnumerable> ObserveConnectionWaitsInProgress() + { + foreach (var (name, reader) in _connectionWaitsInProgressReaders) + { + yield return new Measurement(reader(), new KeyValuePair("poolName", name)); + } + } + + /// Register this pool's reader for the ConnectionWaitsInProgress gauge. + public void SetConnectionWaitsInProgressReader(Func reader) => + _connectionWaitsInProgressReaders[poolName] = reader; + + /// Drop this pool's reader from the gauge registry. + public void ClearConnectionWaitsInProgressReader() => + _connectionWaitsInProgressReaders.TryRemove(poolName, out _); + + /// + /// Elapsed time of one pool-wide MaxConnections cap wait — + /// every AcquirePoolCapSlotAsync invocation with a cap + /// configured, recorded in finally so timeouts / cancellations + /// still tick. Mirrors cppcache connectionWaitTime LongCounter + /// (ns; PoolStatistics.cpp:82-85), timing brackets at + /// ThinClientPoolDM.cpp:1822-1833. .Count subsumes + /// cppcache connectionWaits IntCounter (#13) the same way + /// subsumes + /// locatorRequests / locatorResponses; sum gives total + /// time, mean gives average wait latency. cppcache stores ns; we + /// follow the Histogram convention with <double> seconds + /// (Prometheus / Grafana _seconds suffix). + /// + readonly static Histogram _connectionWaitTime = _meter.CreateHistogram( + "ConnectionWaitTime", + unit: "s", + description: "Elapsed time of one pool-wide MaxConnections cap wait. .Count subsumes cppcache `connectionWaits` IntCounter (#13)."); + + /// Record one sample. + public void ConnectionWait(TimeSpan elapsed) => + _connectionWaitTime.Record(elapsed.TotalSeconds, new KeyValuePair("poolName", poolName)); + + /// + /// Per-pool reader registry for the + /// pull-mode gauge. cppcache pushes via + /// setCurClientOps(++m_clientOps) / + /// setCurClientOps(--m_clientOps) around every + /// sendSyncRequest entry/exit + /// (ThinClientPoolDM.cpp:1272, 1519, 1538); pull-mode reads the + /// live counter on each listener tick. + /// + private static readonly ConcurrentDictionary> _clientOpsInProgressReaders = new(); + + /// + /// Current number of in-flight pool ops (entered + /// but not yet + /// returned/thrown). Mirrors cppcache clientOpsInProgress + /// IntGauge (PoolStatistics.cpp:86-88). + /// + readonly static ObservableGauge _clientOpsInProgress = _meter.CreateObservableGauge( + "ClientOpsInProgress", + observeValues: ObserveClientOpsInProgress, + unit: "clientOps", + description: "Current number of in-flight pool ops. Mirrors cppcache `clientOpsInProgress` IntGauge."); + + private static IEnumerable> ObserveClientOpsInProgress() + { + foreach (var (name, reader) in _clientOpsInProgressReaders) + { + yield return new Measurement(reader(), new KeyValuePair("poolName", name)); + } + } + + /// Register this pool's reader for the ClientOpsInProgress gauge. + public void SetClientOpsInProgressReader(Func reader) => + _clientOpsInProgressReaders[poolName] = reader; + + /// Drop this pool's reader from the gauge registry. + public void ClearClientOpsInProgressReader() => + _clientOpsInProgressReaders.TryRemove(poolName, out _); + + /// + /// Elapsed time of one successful pool op + /// ( returning a + /// reply). Mirrors cppcache clientOpTime LongCounter (ns; + /// PoolStatistics.cpp:92-95), recorded at success sites + /// (ThinClientPoolDM.cpp:1521, 1541). .Count subsumes + /// cppcache clientOps IntCounter (#16; :89-91) the same + /// way subsumes + /// connectionWaits; sum gives total successful-op time, mean + /// gives average op latency. + /// + readonly static Histogram _clientOpTime = _meter.CreateHistogram( + "ClientOpTime", + unit: "s", + description: "Elapsed time of one successful pool op. .Count subsumes cppcache `clientOps` IntCounter (#16)."); + + /// Record one sample (success path). + public void ClientOp(TimeSpan elapsed) => + _clientOpTime.Record(elapsed.TotalSeconds, new KeyValuePair("poolName", poolName)); + + /// + /// Total pool ops that failed for any reason other than timeout. + /// Mirrors cppcache clientOpFailures IntCounter + /// (PoolStatistics.cpp:96-98, bumped at + /// ThinClientPoolDM.cpp:1389, 1525, 1545). + /// + readonly static Counter _clientOpFailures = _meter.CreateCounter( + "ClientOpFailures", + unit: "clientOps", + description: "Total pool ops that failed for any reason other than timeout. Mirrors cppcache `clientOpFailures` IntCounter."); + + /// Bump . + public void ClientOpFailure() => + _clientOpFailures.Add(1, new KeyValuePair("poolName", poolName)); + + /// + /// Total pool ops that ended with a timeout (read-timeout, query + /// timeout, or per-attempt OperationCanceledException not + /// driven by the caller's ct). Mirrors cppcache clientOpTimeouts + /// IntCounter (PoolStatistics.cpp:99-101, bumped at + /// ThinClientPoolDM.cpp:1523, 1543). + /// + readonly static Counter _clientOpTimeouts = _meter.CreateCounter( + "ClientOpTimeouts", + unit: "clientOps", + description: "Total pool ops that ended with a timeout. Mirrors cppcache `clientOpTimeouts` IntCounter."); + + /// Bump . + public void ClientOpTimeout() => + _clientOpTimeouts.Add(1, new KeyValuePair("poolName", poolName)); + + /// + /// Total bytes received from the server on op-channel conns. Mirrors + /// cppcache receivedBytes LongCounter + /// (PoolStatistics.cpp:102-104, bumped at + /// TcrConnection.cpp:513 per socket receive). Our recording + /// fires once per full frame inside + /// ; sum is + /// identical to cppcache's per-receive accumulation (frame total = + /// header + body, no matter how many syscalls). Handshake bytes are + /// not counted — the conn's PoolDM back-ref is wired + /// post-handshake (see + /// xmldoc for the deficit caveat). + /// + readonly static Counter _receivedBytes = _meter.CreateCounter( + "ReceivedBytes", + unit: "bytes", + description: "Total bytes received from the server on op-channel conns. Mirrors cppcache `receivedBytes` LongCounter."); + + /// Bump by . + public void ReceivedBytes(long bytes) => + _receivedBytes.Add(bytes, new KeyValuePair("poolName", poolName)); + + /// ActivitySource for traceable RPC spans. + readonly static ActivitySource _activitySource = new("Geode.Client.Pool", _assemblyVersion); + + /// Start an Activity span for a LocatorListRequest RPC. + public Activity? StartLocatorListRequest() => + _activitySource.StartActivity("LocatorListRequest", ActivityKind.Client)?.SetTag("poolName", poolName); + + /// Start an Activity span for a ClientConnectionRequest RPC. + public Activity? StartClientConnectionRequest() => + _activitySource.StartActivity("ClientConnectionRequest", ActivityKind.Client)?.SetTag("poolName", poolName); +} diff --git a/src/Geode.Client/Internal/ProxyRemoteQueryService.cs b/src/Geode.Client/Internal/ProxyRemoteQueryService.cs new file mode 100644 index 0000000..e6c399a --- /dev/null +++ b/src/Geode.Client/Internal/ProxyRemoteQueryService.cs @@ -0,0 +1,31 @@ +namespace Geode.Client.Internal; + +/// +/// Per-user proxy variant of . Mirrors +/// cppcache ProxyRemoteQueryService +/// (cppcache/src/ProxyRemoteQueryService.hpp/.cpp); Phase 3 +/// (multi-user security) placeholder so the wiring point exists when +/// AuthenticatedView lands. +/// +/// +/// cppcache's ctor takes AuthenticatedView* and stores the +/// real per-pool ; newQuery +/// dispatches via the AuthenticatedView's selected pool to that +/// pool's actual query service. Continuous Query methods on cppcache's +/// version are out of scope until Phase 2. +/// +internal sealed class ProxyRemoteQueryService : IQueryService +{ + /// + public IQuery NewQuery(string oql) + { + // Mirrors cppcache ProxyRemoteQueryService::newQuery + // (cppcache/src/ProxyRemoteQueryService.cpp:33-50): resolve the + // AuthenticatedView's pool DM, then forward to that pool's real + // RemoteQueryService. Phase 3 scope — nothing constructs this + // class today. + throw new NotImplementedException( + "Phase 3 multi-user authentication is not yet implemented."); + } +} + diff --git a/src/Geode.Client/Internal/RegionAttributes.cs b/src/Geode.Client/Internal/RegionAttributes.cs new file mode 100644 index 0000000..33e84c8 --- /dev/null +++ b/src/Geode.Client/Internal/RegionAttributes.cs @@ -0,0 +1,60 @@ +namespace Geode.Client.Internal; + +/// +/// Region configuration bag built by . +/// Snapshotted via at +/// time so further factory mutations don't affect already-built regions. +/// +/// +/// Mirrors cppcache RegionAttributes +/// (cppcache/include/geode/RegionAttributes.hpp + +/// cppcache/src/RegionAttributes.cpp:36-58) 1:1. Phase 1.x carries +/// only the fields exposed through setters; +/// expiration / listener / persistence / partition-resolver fields land +/// in Phase 2+. +/// +internal sealed class RegionAttributes +{ + // RegionAttributes.cpp:50 — m_initialCapacity(10000) + public int InitialCapacity { get; set; } = 10000; + + // RegionAttributes.cpp:51 — m_loadFactor(0.75) + public float LoadFactor { get; set; } = 0.75f; + + // RegionAttributes.cpp:52 — m_concurrencyLevel(16) + public int ConcurrencyLevel { get; set; } = 16; + + // RegionAttributes.cpp:43 — m_lruEntriesLimit(0) + public int LruEntriesLimit { get; set; } + + // RegionAttributes.cpp:44 — m_caching(true) + public bool CachingEnabled { get; set; } = true; + + // RegionAttributes.cpp:57 — m_isClonable(false) + public bool CloningEnabled { get; set; } + + // RegionAttributes.cpp:58 — m_isConcurrencyChecksEnabled(true) + public bool ConcurrencyChecksEnabled { get; set; } = true; + + // RegionAttributes.cpp:501 — m_poolName default empty string + public string PoolName { get; set; } = string.Empty; + + /// + /// Deep copy used by so the + /// region carries an immutable view independent of further setter calls. + /// + public RegionAttributes Clone() + { + return new RegionAttributes + { + InitialCapacity = InitialCapacity, + LoadFactor = LoadFactor, + ConcurrencyLevel = ConcurrencyLevel, + LruEntriesLimit = LruEntriesLimit, + CachingEnabled = CachingEnabled, + CloningEnabled = CloningEnabled, + ConcurrencyChecksEnabled = ConcurrencyChecksEnabled, + PoolName = PoolName, + }; + } +} diff --git a/src/Geode.Client/Internal/RegionAttributesFactory.cs b/src/Geode.Client/Internal/RegionAttributesFactory.cs new file mode 100644 index 0000000..79eae9c --- /dev/null +++ b/src/Geode.Client/Internal/RegionAttributesFactory.cs @@ -0,0 +1,95 @@ +namespace Geode.Client.Internal; + +/// +/// Mutable builder for ; the inner layer of +/// the cppcache region-creation 3-tuple +/// (RegionFactoryRegionAttributesFactoryRegionAttributes). +/// +/// +/// +/// Mirrors cppcache RegionAttributesFactory +/// (cppcache/include/geode/RegionAttributesFactory.hpp). cppcache +/// keeps this layer public so cache.xml parsing and the +/// sub-region API (Region::createSubregion(name, attrs)) can build +/// attributes standalone; both Phase 2+ for us, so the class is +/// until a real consumer surfaces. +/// +/// +/// Phase 1.x setters mirror the ones exposed through +/// ; expiration / listener / persistence / +/// partition-resolver / cacheLoader / cacheWriter / diskPolicy land in +/// Phase 2+ together with their backing fields on +/// . +/// +/// +internal sealed class RegionAttributesFactory +{ + private readonly RegionAttributes _attrs; + + /// Default-initialised attributes (cppcache RegionAttributesFactory()). + public RegionAttributesFactory() + { + _attrs = new RegionAttributes(); + } + + /// Initialise from an existing snapshot (cppcache explicit RegionAttributesFactory(const RegionAttributes)). + public RegionAttributesFactory(RegionAttributes seed) + { + ArgumentNullException.ThrowIfNull(seed); + _attrs = seed.Clone(); + } + + public RegionAttributesFactory SetPoolName(string poolName) + { + _attrs.PoolName = poolName; + return this; + } + + public RegionAttributesFactory SetInitialCapacity(int initialCapacity) + { + _attrs.InitialCapacity = initialCapacity; + return this; + } + + public RegionAttributesFactory SetLoadFactor(float loadFactor) + { + _attrs.LoadFactor = loadFactor; + return this; + } + + public RegionAttributesFactory SetConcurrencyLevel(int concurrencyLevel) + { + _attrs.ConcurrencyLevel = concurrencyLevel; + return this; + } + + public RegionAttributesFactory SetLruEntriesLimit(int entriesLimit) + { + _attrs.LruEntriesLimit = entriesLimit; + return this; + } + + public RegionAttributesFactory SetCachingEnabled(bool cachingEnabled) + { + _attrs.CachingEnabled = cachingEnabled; + return this; + } + + public RegionAttributesFactory SetCloningEnabled(bool cloningEnabled) + { + _attrs.CloningEnabled = cloningEnabled; + return this; + } + + public RegionAttributesFactory SetConcurrencyChecksEnabled(bool concurrencyChecksEnabled) + { + _attrs.ConcurrencyChecksEnabled = concurrencyChecksEnabled; + return this; + } + + /// + /// Snapshot the current attribute set; mirrors cppcache + /// RegionAttributesFactory::create() (return-by-value). + /// + public RegionAttributes Create() => _attrs.Clone(); +} diff --git a/src/Geode.Client/Internal/RegionInternal.cs b/src/Geode.Client/Internal/RegionInternal.cs new file mode 100644 index 0000000..348d7fe --- /dev/null +++ b/src/Geode.Client/Internal/RegionInternal.cs @@ -0,0 +1,64 @@ +namespace Geode.Client.Internal; + +/// +/// Abstract internal layer between the public +/// interface and the concrete region implementations +/// (ThinClientRegion). +/// Mirrors cppcache RegionInternal +/// (cppcache/src/RegionInternal.hpp:131). +/// +/// +/// +/// cppcache uses this layer to expose internal-only operations that +/// the public Region interface doesn't surface (event flags, +/// version tags, tombstones, internal Put / Get variants that take +/// EventId + VersionTag). All of those land in their +/// respective phases — Phase 1.x keeps the layer mostly empty so the +/// inheritance chain matches cppcache for future ports. +/// +/// +/// cppcache ctor takes (CacheImpl*, RegionAttributes); the +/// cache back-pointer is deferred — first consumer that needs it +/// (likely the serialization registry in Phase 2 or stats in Phase +/// 1.5) will add it. +/// +/// +internal abstract class RegionInternal(RegionAttributes attributes) + : IRegion +{ + /// + /// Region attributes snapshot taken at + /// time. Mirrors cppcache RegionInternal::m_regionAttributes. + /// + protected RegionAttributes Attributes { get; } = attributes; + + // ── IRegion (forward to derived) ─────────────────────────── + public abstract string Name { get; } + public abstract string FullPath { get; } + + /// + /// Mirrors cppcache RegionAttributes::getPoolName(); resolved + /// by to either the + /// caller-supplied pool name or the cache's default pool name. + /// + public string PoolName => Attributes.PoolName; + + public abstract Task PutAsync(object key, object value, CancellationToken ct = default); + public abstract Task GetAsync(object key, CancellationToken ct = default); + public abstract Task RemoveAsync(object key, CancellationToken ct = default); + public abstract Task ContainsKeyAsync(object key, CancellationToken ct = default); + public abstract Task ClearAsync(CancellationToken ct = default); + public abstract Task InvalidateAsync(object key, CancellationToken ct = default); + public abstract Task RemoveAllAsync(IReadOnlyCollection keys, CancellationToken ct = default); + public abstract Task PutAllAsync(IReadOnlyDictionary map, CancellationToken ct = default); + public abstract Task> GetAllAsync(IReadOnlyCollection keys, CancellationToken ct = default); + public abstract Task ExistsValueAsync(string predicate, CancellationToken ct = default); + public abstract Task SelectValueAsync(string predicate, CancellationToken ct = default); + + // TODO future phases — internal-only API surface that cppcache + // RegionInternal exposes; add as their respective phases ship: + // Phase 2+: putNoThrow_remote / getNoThrow_remote (EventId-aware) + // versionStamp / tombstoneList / cacheImpl back-ref + // Phase 4: single-hop / partitioned-region helpers + // Sub-region phase: createSubRegion / getSubRegion / subRegions +} diff --git a/src/Geode.Client/Internal/RegionView.cs b/src/Geode.Client/Internal/RegionView.cs new file mode 100644 index 0000000..61a63a1 --- /dev/null +++ b/src/Geode.Client/Internal/RegionView.cs @@ -0,0 +1,222 @@ +using Geode.Client.Protocol.Serialization; + +namespace Geode.Client.Internal; + +/// +/// Compile-time-only typed view over a non-generic . +/// Returned by ; one +/// fresh instance per call (cheap throwaway). No cppcache analogue — +/// cppcache native Region is non-generic, and the C++/CLI +/// clicache typed wrapper sits at a different layer (the public +/// API). C# folds both layers into one: the non-generic +/// carries the wire path; this +/// implementation just adds compile-time type guards. +/// +/// +/// +/// No runtime type binding. A region can be viewed under any +/// <TKey, TValue> pair the caller picks — the wrapper +/// boxes the typed args into and forwards. Wrong +/// types surface as from the unbox +/// inside , never as a "type already bound" +/// error. +/// +/// +/// Not cached. Each GetRegion<K,V>(name) call +/// allocates a fresh wrapper. The wrapper holds one reference and +/// nothing else, so allocation cost is negligible; if a profile ever +/// disagrees, add a ConditionalWeakTable on +/// keyed by the inner region. +/// +/// +internal sealed class RegionView : IRegion + where TKey : IEquatable +{ + private readonly IRegion _inner; + private readonly TypedResultAdapter _adapter; + + public RegionView(IRegion inner, TypedResultAdapter adapter) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(adapter); + _inner = inner; + _adapter = adapter; + } + + // ── Metadata pass-through ────────────────────────────────── + public string Name => _inner.Name; + public string PoolName => _inner.PoolName; + public string FullPath => _inner.FullPath; + + // ── Typed ops (the C# call-site shape) ───────────────────── + public Task PutAsync(TKey key, TValue value, CancellationToken ct = default) + => _inner.PutAsync(key, value!, ct); + + public async Task GetAsync(TKey key, CancellationToken ct = default) + { + var raw = await _inner.GetAsync(key, ct).ConfigureAwait(false); + // Adapter reshapes wire-canonical containers (List from + // CacheableArrayList, etc.) into the declared TValue form — + // List, IList>, int[], …. Scalars and + // primitive arrays early-out unchanged via IsInstanceOfType. + // Null in → default(TValue) out (matches .NET dictionary + // conventions: missing reference value = null, missing value + // type = zero). InvalidCastException surfaces here when the + // stored value's shape genuinely doesn't fit TValue — caller + // is asking the wrong typed view for this region. + return _adapter.Convert(raw); + } + + public Task RemoveAsync(TKey key, CancellationToken ct = default) + => _inner.RemoveAsync(key, ct); + + public Task ContainsKeyAsync(TKey key, CancellationToken ct = default) + => _inner.ContainsKeyAsync(key, ct); + + public Task ClearAsync(CancellationToken ct = default) + => _inner.ClearAsync(ct); + + public Task InvalidateAsync(TKey key, CancellationToken ct = default) + => _inner.InvalidateAsync(key!, ct); + + // No alias needed — bool return doesn't depend on TValue, the base + // IRegion's ExistsValueAsync member satisfies the inherited contract + // and is picked up by the typed view automatically. + public Task ExistsValueAsync(string predicate, CancellationToken ct = default) + => _inner.ExistsValueAsync(predicate, ct); + + public async Task SelectValueAsync(string predicate, CancellationToken ct = default) + { + var raw = await _inner.SelectValueAsync(predicate, ct).ConfigureAwait(false); + // Same adapter path as GetAsync: scalar/array shortcut via + // IsInstanceOfType, otherwise reshape wire-canonical containers + // (List, etc.) into TValue. Null in → default(TValue?). + return _adapter.Convert(raw); + } + + public Task RemoveAllAsync(IReadOnlyCollection keys, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(keys); + // Box typed keys to object[] and forward; the inner region is + // non-generic so we can't pass the typed collection straight + // through. New array per call — bulk ops are not on the + // allocation-critical path. + var boxed = new object[keys.Count]; + var i = 0; + foreach (var k in keys) + { + boxed[i++] = k!; + } + return _inner.RemoveAllAsync(boxed, ct); + } + + public Task PutAllAsync(IReadOnlyDictionary map, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(map); + // Box typed entries into a fresh Dictionary; + // the inner region is non-generic so the typed dict can't ride + // through (covariance doesn't apply to IReadOnlyDictionary). + // Allocation matches RemoveAllAsync — bulk ops aren't on the + // hot path. + var boxed = new Dictionary(map.Count); + foreach (var kv in map) + { + boxed[kv.Key!] = kv.Value!; + } + return _inner.PutAllAsync(boxed, ct); + } + + public async Task> GetAllAsync( + IReadOnlyCollection keys, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(keys); + // Box typed keys → object[]; the wire path is object-typed. + var boxed = new object[keys.Count]; + var i = 0; + foreach (var k in keys) + { + boxed[i++] = k!; + } + var raw = await _inner.GetAllAsync(boxed, ct).ConfigureAwait(false); + + // Reshape Dictionary → Dictionary + // via TypedResultAdapter — same recursive-descent path that + // GetAsync uses for nested generics. Each value goes through + // Convert(raw) so a region declared + // > still gets List → List + // reshaping per entry; missing-key entries (null value) collapse + // to default(TValue?). + var typed = new Dictionary(raw.Count); + foreach (var kv in raw) + { + // Skip server-missing entries at the typed boundary. + // + // The non-typed inner layer (cppcache parity) keeps null + // values to represent "key not on server" — that works + // there because the value slot is object?. On the typed + // layer the result type is IReadOnlyDictionary but TValue? for an unconstrained generic is NOT + // Nullable at runtime (only a compile-time + // nullability annotation), so for value-type TValue + // (e.g. int) a "null wire value" would collapse to + // default(TValue)=0 and become indistinguishable from a + // legitimately-stored 0. .NET idiom for "absent key" is + // dict.ContainsKey/TryGetValue returning false; skipping + // the null here makes the typed surface unambiguous and + // matches the XML-doc contract on + // . + // + // Safe because Phase 1.3 PutAsync / PutAllAsync both + // ArgumentNullException-guard the value — a region never + // stores a null value legitimately, so null on the wire + // is always the cppcache miss-flag-3 sentinel. + if (kv.Value is null) + { + continue; + } + + // Key reshape is straightforward — inner Keys mirror what we + // passed in (we sent object-boxed TKey, server echoes none — + // ChunkedGetAllResponse threads our original keys back into + // the result dict), so a direct cast holds. The "!" silences + // CS8600 for the K reference-type case; if it ever fails the + // InvalidCastException is the right surface (wrong typed view). + var key = (TKey)kv.Key; + typed[key] = _adapter.Convert(kv.Value); + } + return typed; + } + + // ── Object-typed ops (explicit interface — forward to inner) ── + Task IRegion.PutAsync(object key, object value, CancellationToken ct) + => _inner.PutAsync(key, value, ct); + + Task IRegion.GetAsync(object key, CancellationToken ct) + => _inner.GetAsync(key, ct); + + Task IRegion.RemoveAsync(object key, CancellationToken ct) + => _inner.RemoveAsync(key, ct); + + Task IRegion.ContainsKeyAsync(object key, CancellationToken ct) + => _inner.ContainsKeyAsync(key, ct); + + Task IRegion.InvalidateAsync(object key, CancellationToken ct) + => _inner.InvalidateAsync(key, ct); + + Task IRegion.RemoveAllAsync(IReadOnlyCollection keys, CancellationToken ct) + => _inner.RemoveAllAsync(keys, ct); + + Task IRegion.PutAllAsync(IReadOnlyDictionary map, CancellationToken ct) + => _inner.PutAllAsync(map, ct); + + Task> IRegion.GetAllAsync( + IReadOnlyCollection keys, CancellationToken ct) + => _inner.GetAllAsync(keys, ct); + + // Explicit-interface overload for the object-typed SelectValueAsync; + // the typed implicit member above shadows the base via `new`, so + // calls through an IRegion reference need this explicit forwarder + // to skip the adapter and return the raw object?. + Task IRegion.SelectValueAsync(string predicate, CancellationToken ct) + => _inner.SelectValueAsync(predicate, ct); +} diff --git a/src/Geode.Client/Internal/RemoteQuery.cs b/src/Geode.Client/Internal/RemoteQuery.cs new file mode 100644 index 0000000..917b878 --- /dev/null +++ b/src/Geode.Client/Internal/RemoteQuery.cs @@ -0,0 +1,176 @@ +using Geode.Client.Protocol; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Concrete . Mirrors cppcache RemoteQuery +/// (cppcache/src/RemoteQuery.hpp/.cpp), reduced to the Phase 1.4 +/// surface ??compile() / isCompiled() were never +/// supported upstream and are omitted; the multi-user +/// AuthenticatedView field reappears in Phase 3. +/// +/// +/// Created by . Not +/// thread-safe per cppcache contract; use one instance per +/// thread / scope. +/// +internal sealed class RemoteQuery( + string oql, + RemoteQueryService queryService, + ThinClientBaseDM dm, + IServiceProvider serviceProvider, + ILogger> logger) : IQuery +{ + // One TcrMessageHelper per query instance — passed positionally to + // the ChunkedQueryResponse collector below so TcrMessageHelper / + // SerializationRegistry don't need DI aliases. + private readonly TcrMessageHelper _tcrMessageHelper = + ActivatorUtilities.CreateInstance(serviceProvider); + + + /// + public string QueryString { get; } = oql; + + /// + public TimeSpan ResponseTimeout { get; set; } = TimeSpan.FromSeconds(15); + public IList Parameters { get; } = []; + + /// + public Task> ExecuteAsync(CancellationToken ct = default) + => ExecuteCoreAsync(ct); + + // ?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?�?� + // Shared execution path. Mirrors cppcache RemoteQuery::execute + // + executeNoThrow merged (RemoteQuery.cpp:67-182). Both public + // ExecuteAsync overloads delegate here. + // + // ?�?� Pre-requisite work ?�?� + // A1. TcrMessageBuilder.Query ??done + // A2. TcrMessageBuilder.QueryWithParameters ??done + // A3. ChunkedQueryResponse (TcrChunkedResult) ??pending + // + // ?�?� Phase 1.4 skipped (cppcache surface we omit) ?�?� + // ??GuardUserAttributes / AuthenticatedView binding (Phase 3) + // ??pool->getStats().incQueryExecutionId() (Phase 1.5 stats) + // ??enableTimeStatistics / sampleStartNanos (Phase 1.5 stats) + // ??PROTOCOL_OPERATION_TIMEOUT_BOUNDS validation + // ??compile() / isCompiled() ??cppcache itself throws unsupported + // + private async Task> ExecuteCoreAsync(CancellationToken ct) + { + // B1 ??Closed guard. cppcache RemoteQuery.cpp:127-130: + // shared_lock(m_queryService->getMutex()); + // if (m_queryService->invalid()) return GF_CACHE_CLOSED_EXCEPTION; + // cppcache's shared_lock against destroy is not ported ??the + // race window is benign (B6's wire send fails naturally if + // the pool's connections are gone). See RemoteQueryService.IsClosed. + ObjectDisposedException.ThrowIf(queryService.IsClosed, queryService); + + // B2 ??Log "executing query". cppcache RemoteQuery.cpp:125 + // LOGFINEST("%s: executing query: %s", func, m_queryString) + // ("func" is the cppcache call-site label, always + // "Query::execute" for this path ??kept verbatim so + // side-by-side cppcache trace comparisons line up.) + logger.LogTrace("Query::execute: executing query: {Oql}", QueryString); + + // B3 ??Build TcrMessage request. cppcache RemoteQuery.cpp:132-139 + // (Query(34)) / 158-162 (QueryWithParameters(80)). ResponseTimeout + // ??ms (cppcache m_messageResponseTimeout). Wire branch decided + // by Parameters.Count: empty ??Query(34), non-empty ?? // QueryWithParameters(80). + var timeoutMs = (int)ResponseTimeout.TotalMilliseconds; + TcrMessage request; + if (Parameters.Count == 0) + { + // Query(34) wire layout mirrors cppcache TcrMessageQuery + // (TcrMessage.cpp:1684-1709): RegionPart(querystring) + + // EventId + i32 timeout-millis. cppcache writeMillisecondsPart + // is writeIntPart(int32) under the hood — same as AddInt32Part. + var (threadId, sequenceId) = dm.Cache.EventIdGenerator.Next(); + request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.Query) + .AddRegionNamePart(QueryString) + .AddEventIdPart(threadId, sequenceId) + .AddInt32Part(timeoutMs) + .BuildAsync(ct); + } + else + { + // QueryWithParameters(80) wire mirrors cppcache + // TcrMessageQueryWithParameters (TcrMessage.cpp:1769-1806): + // 4 + N parts (RegionPart(querystring) + i32 paramCount + + // i32 compileTimeout(15) + i32 responseTimeoutMs + + // N×ObjectPart(param)). No EventId — cppcache ctor doesn't + // call writeEventIdPart. + const int CompileQueryClearTimeout = 15; + var builder = TcrMessageBuilder + .Create(serviceProvider, MessageType.QueryWithParameters) + .AddRegionNamePart(QueryString) + .AddInt32Part(Parameters.Count) + .AddInt32Part(CompileQueryClearTimeout) + .AddInt32Part(timeoutMs); + foreach (var param in Parameters) + { + builder = builder.AddValuePart(dm.Cache, param!); + } + request = await builder.BuildAsync(ct); + } + + // B4 ??Build ChunkedQueryResponse collector (A3). cppcache + // RemoteQuery.cpp:84-87 ??std::unique_ptr + // bound to reply via setChunkedResultHandler. Our chunked DM + // overload takes the collector directly in B6; nothing to bind + // here, just construct. ActivatorUtilities mirrors what + // ThinClientRegion does for its Chunked*Response collectors. + var collector = ActivatorUtilities.CreateInstance>( + serviceProvider, _tcrMessageHelper, dm.Cache.SerializationRegistry); + + // B5 ??Log "sending request". cppcache RemoteQuery.cpp:143 + // (Query branch) / :166 (QueryWithParameters branch) ??same + // LOGFINEST text in both paths. + logger.LogTrace("Query::execute: sending request for query: {Oql}", QueryString); + + // B6 ??Wire. cppcache RemoteQuery.cpp:147 (Query branch) / :170 + // (QueryWithParameters branch): err = tcdm->sendSyncRequest(msg, reply). + // Connection error surfaces as IOException / GeodeException + // (.NET exceptions replace cppcache GfErrType). + var reply = await dm + .SendSyncRequestAsync(request, collector, ct: ct) + .ConfigureAwait(false); + + // B7 ??Server-exception handling. cppcache RemoteQuery.cpp:151-156 + // (Query) / :174-179 (QueryWithParameters). cppcache only + // special-cases EXCEPTION here; any other reply type falls + // through to read collector results. We mirror that ??strict + // "unexpected MessageType" guard can land if integration tests + // surface a server quirk worth catching. + if (reply.MessageType == MessageType.Exception) + { + throw new GeodeException( + $"Server exception on Query '{QueryString}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + } + + // B8 ??Log "reading reply". cppcache RemoteQuery.cpp:93. + logger.LogTrace("Query::execute: reading reply for query: {Oql}", QueryString); + + // B9 / B10 ??Read collector.Results directly. The collector + // stores already-typed rows (List): single-column queries + // push cast row values, multi-column projection pushes + // assembled QueryStruct per row. cppcache's RemoteQuery.cpp:94-111 + // does the ResultSetImpl / StructSetImpl wrapping at this site; + // we collapse it into the collector so this leg is one line. + // + // B11 ??Log "creating result set". cppcache RemoteQuery.cpp:98 / :107. + logger.LogTrace("Query::execute: creating result set for query: {Oql}", QueryString); + + // collector.Results is IReadOnlyList; ExecuteAsync returns + // IReadOnlyList. Same runtime type for unconstrained T; the + // `!` suppresses the nullability annotation gap (caller takes + // null elements as they come ??server may send NULL row values). + return collector.Results!; + } + +} diff --git a/src/Geode.Client/Internal/RemoteQueryService.cs b/src/Geode.Client/Internal/RemoteQueryService.cs new file mode 100644 index 0000000..5cb1d0f --- /dev/null +++ b/src/Geode.Client/Internal/RemoteQueryService.cs @@ -0,0 +1,165 @@ +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Pool-scoped (or, eventually, cache-scoped) implementation of +/// . Mirrors cppcache +/// RemoteQueryService +/// (cppcache/src/RemoteQueryService.hpp/.cpp), reduced to the +/// Phase 1.4 surface (no CQ). +/// +/// +/// +/// Owned by , one instance per pool — +/// matches cppcache's pool-mode ctor path +/// (m_tccdm = poolDM). The non-pool path +/// (m_tccdm = new ThinClientCacheDistributionManager(...)) is +/// deferred per memory pool-only-no-non-pool.md; the +/// ctor parameter keeps that door open +/// without forcing it. +/// +/// +/// Construction is cheap (no I/O). cppcache init() only does +/// work when CQ is enabled — Phase 1.4 omits the init entry point +/// entirely; it reappears with CQ in Phase 2. +/// +/// +internal sealed class RemoteQueryService : IQueryService +{ + private readonly ThinClientBaseDM _dm; + private readonly IServiceProvider _serviceProvider; + private readonly SerializationRegistry _serializationRegistry; + private readonly ILogger _logger; + + public RemoteQueryService( + ThinClientBaseDM dm, + IServiceProvider serviceProvider, + SerializationRegistry serializationRegistry, + ILogger logger) + { + _dm = dm; + _serviceProvider = serviceProvider; + _serializationRegistry = serializationRegistry; + _logger = logger; + + // cppcache RemoteQueryService.cpp:46 — LOGFINEST("Initialized m_tccdm"). + _logger.LogTrace("Initialized m_tccdm"); + } + + /// + /// Mirrors cppcache m_invalid: 0 = live, 1 = closed. + /// cppcache ctor sets it true and init() flips it false; + /// Phase 1.4 has no init step (pool-mode RQS init is a no-op + /// besides the flag, CQ-only work lives in Phase 2), so we start + /// at 0 directly. flips to 1. + /// + private int _invalid; + + /// + /// True after . Read by + /// 's closed guard + /// (cppcache RemoteQuery::executeNoThrow step that checks + /// m_queryService->invalid()). Best-effort: cppcache wraps + /// the read in a shared_lock against destroy, we do not — + /// the actual wire op will fail naturally if the pool's + /// connections are gone post-Close, so the race is benign. + /// + internal bool IsClosed => Volatile.Read(ref _invalid) != 0; + + public IQuery NewQuery(string oql) + { + // step 1 — input validation. cppcache does not; server's OQL + // parser catches empty / whitespace. We fail fast client-side. + ArgumentException.ThrowIfNullOrWhiteSpace(oql); + + // step 2 — Phase 1.4 row-type guard. Supports: + // bucket 0 (untyped passthrough) — T == object. cppcache + // Query::execute returns shared_ptr (≈ object?); + // this T is the .NET equivalent and the path region + // convenience methods (ExistsValue / SelectValue) take. + // TypedResultAdapter.Convert is a clean identity. + // bucket 1 (single-column basic) — T has a + // SerializationRegistry converter (int / string / byte[] / + // List / ...). + // bucket 3 (multi-column projection) — T == QueryStruct. + // Buckets 2 (PDX single-column) and 4 (ORM-mapped multi-column) + // ship in later phases; throw NotSupportedException early so + // caller doesn't discover the gap mid-flight. + if (typeof(T) != typeof(object) + && typeof(T) != typeof(QueryStruct) + && !_serializationRegistry.IsRegistered(typeof(T))) + { + throw new NotSupportedException( + $"IQuery<{typeof(T).Name}>: Phase 1.4 supports {nameof(Object)} " + + $"(cppcache-parity untyped), basic wire-registered types, and " + + $"{nameof(QueryStruct)} only. PDX (single-column custom) and " + + "ORM mapping (multi-column to user types) land in later phases."); + } + + // step 3 — closed guard. Mirrors cppcache + // RemoteQueryService::newQuery's `if (m_invalid) throw + // CacheClosedException(...)`. ObjectDisposedException is the + // .NET-side parallel (memory use-bcl-exceptions.md: BCL for + // lifecycle misuse, GeodeException for protocol failures). + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _invalid) != 0, this); + + // step 4 — diagnostics. Mirrors cppcache + // RemoteQueryService.cpp:69-71: + // LOGDEBUG("...newQuery: multiuserMode = %d", ...) + // LOGDEBUG("RemoteQueryService: creating a new query: " + qs) + // multiuserMode is always false in Phase 1.4 (Phase 3 will + // surface ThinClientBaseDM.IsMultiUserMode); the log line is + // kept so cppcache trace comparisons line up. + _logger.LogDebug( + "RemoteQueryService::newQuery: multiuserMode = {MultiUser}", + _dm.IsMultiUserMode); + _logger.LogDebug( + "RemoteQueryService: creating a new query: {Oql}", oql); + + // step 5 — multi-user branch. Mirrors cppcache + // RemoteQueryService.cpp:73-80. Phase 1.4 _dm.IsMultiUserMode + // is hard-wired false (ThinClientBaseDM default), so this + // branch is structurally unreachable today; it stays here so + // Phase 3 only has to fill in the AuthenticatedView bind and + // delete the NIE. + if (_dm.IsMultiUserMode) + { + // cppcache: + // return std::make_shared( + // querystring, shared_from_this(), m_tccdm, + // UserAttributes::threadLocalUserAttributes->getAuthenticatedView()); + throw new NotImplementedException( + "Multi-user authentication mode is Phase 3 scope."); + } + + // step 6/7 — single-user build + return. Mirrors cppcache + // RemoteQueryService.cpp:78-79: + // return std::make_shared(querystring, + // shared_from_this(), m_tccdm); + // Built via ActivatorUtilities so future DI-resolved deps + // (logger, TypedResultAdapter, SerializationRegistry) flow in + // automatically — oql / this / _dm supply the non-DI args. + return ActivatorUtilities.CreateInstance>( + _serviceProvider, oql, this, _dm); + } + + /// + /// Mark this service closed; subsequent + /// calls throw . Mirrors + /// cppcache RemoteQueryService::close() reduced to the + /// Phase 1.4 surface — CQ service teardown + /// (m_cqService->closeCqService()) and non-pool DM + /// destroy reappear in Phase 2 / when non-pool mode ships. + /// Idempotent. + /// + internal void Close() + { + _logger.LogTrace("RemoteQueryService::close: starting close"); + Interlocked.Exchange(ref _invalid, 1); + _logger.LogTrace("RemoteQueryService::close: completed"); + } +} diff --git a/src/Geode.Client/Internal/ServerLocation.cs b/src/Geode.Client/Internal/ServerLocation.cs new file mode 100644 index 0000000..127a836 --- /dev/null +++ b/src/Geode.Client/Internal/ServerLocation.cs @@ -0,0 +1,20 @@ +namespace Geode.Client.Internal; + +/// +/// A Geode server's network location (host + port). Mirrors cppcache +/// ServerLocation (cppcache/src/ServerLocation.hpp) — the +/// wire-layer value type returned by locator-protocol responses +/// (LocatorListResponse, GetAllServersResponse). +/// +/// +/// Distinct from (runtime endpoint +/// identity, no wire codec) and +/// (options +/// layer, JSON-bindable). cppcache ServerGroup is **not** a +/// field of ServerLocation — it is a separate parameter at the +/// call site (e.g. updateLocators(serverGrp)). +/// DataSerializable wire codec lands in Step B of the locator helper +/// roadmap (see ThinClientPoolDM.UpdateLocatorsLocalAsync). +/// +internal sealed record ServerLocation(string Host, int Port); + diff --git a/src/Geode.Client/Internal/SystemProperties.cs b/src/Geode.Client/Internal/SystemProperties.cs new file mode 100644 index 0000000..04f2150 --- /dev/null +++ b/src/Geode.Client/Internal/SystemProperties.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Per-cache client-wide settings bag. Mirrors cppcache +/// SystemProperties (cppcache/include/geode/SystemProperties.hpp) +/// 1:1, all fields included; built from the per-cache +/// GeodeClientOptions snapshot at scope-construction time. +/// +/// +/// Defaults match cppcache Default* constants in +/// cppcache/src/SystemProperties.cpp:78-126. Fields are init-only: +/// settings are bound once when the scope is built and read-only thereafter. +/// cppcache exposes runtime setters on a handful of fields +/// (setLogLevel / setEnableChunkHandlerThread / etc.); not +/// mirrored until a real consumer needs them. +/// +internal sealed class SystemProperties +{ + // SystemProperties.cpp:87 — DefaultSamplingInterval = seconds(1) + public TimeSpan StatisticsSampleInterval { get; init; } = TimeSpan.FromSeconds(1); + + // SystemProperties.cpp:88 — DefaultSamplingEnabled = false + public bool StatisticsEnabled { get; init; } + + // SystemProperties.cpp:90 — DefaultStatArchive = "statArchive.gfs" + public string StatisticsArchiveFile { get; init; } = "statArchive.gfs"; + + // SystemProperties.cpp:91 — DefaultLogFilename = "" (stdout) + public string LogFilename { get; init; } = string.Empty; + + // SystemProperties.cpp:93-94 — DefaultLogLevel = LogLevel::Config. + // .NET LogLevel has no "Config"; Information is the closest level. + public LogLevel LogLevel { get; init; } = LogLevel.Information; + + // SystemProperties.cpp:144 — m_disableShufflingEndpoint(false) + public bool DisableShufflingEndpoint { get; init; } + + // SystemProperties.hpp:233 — name() / DefaultName = "" + public string Name { get; init; } = string.Empty; + + // SystemProperties.hpp:235 — cacheXMLFile() / DefaultCacheXMLFile = "" + public string CacheXMLFile { get; init; } = string.Empty; + + // SystemProperties.cpp:107 — DefaultLogFileSizeLimit = 0 (unlimited) + public uint LogFileSizeLimit { get; init; } + + // SystemProperties.cpp:108 — DefaultLogDiskSpaceLimit = 0 (unlimited) + public uint LogDiskSpaceLimit { get; init; } + + // SystemProperties.cpp:109 — DefaultStatsFileSizeLimit = 0 (unlimited) + public uint StatsFileSizeLimit { get; init; } + + // SystemProperties.cpp:110 — DefaultStatsDiskSpaceLimit = 0 (unlimited) + public uint StatsDiskSpaceLimit { get; init; } + + // SystemProperties.cpp:96 — DefaultConnectionPoolSize = 5 + public uint ConnectionPoolSize { get; init; } = 5; + + // SystemProperties.cpp:112 — DefaultHeapLRULimit = 0 (disabled) + public long HeapLRULimit { get; init; } + + // SystemProperties.cpp:113 — DefaultHeapLRUDelta = 10 (% eviction step) + public int HeapLRUDelta { get; init; } = 10; + + // SystemProperties.cpp:115 — DefaultMaxSocketBufferSize = 65 * 1024 + public int MaxSocketBufferSize { get; init; } = 65 * 1024; + + // SystemProperties.cpp:116 — DefaultPingInterval = seconds(10) + public TimeSpan PingInterval { get; init; } = TimeSpan.FromSeconds(10); + + // SystemProperties.cpp:117 — DefaultRedundancyMonitorInterval = seconds(10) + public TimeSpan RedundancyMonitorInterval { get; init; } = TimeSpan.FromSeconds(10); + + // SystemProperties.cpp:118 — DefaultNotifyAckInterval = seconds(1) + public TimeSpan NotifyAckInterval { get; init; } = TimeSpan.FromSeconds(1); + + // SystemProperties.cpp:119 — DefaultNotifyDupCheckLife = seconds(300) + public TimeSpan NotifyDupCheckLife { get; init; } = TimeSpan.FromSeconds(300); + + // SystemProperties.hpp:386 — m_securityPropertiesPtr (shared_ptr bag) + public IReadOnlyDictionary SecurityProperties { get; init; } = + new Dictionary(); + + // SystemProperties.hpp:294 — securityClientDhAlgo; marked _GEODE_DEPRECATED_ + [Obsolete("Diffie-Hellman based credentials encryption is not supported.")] + public string SecurityClientDhAlgo { get; init; } = string.Empty; + + // SystemProperties.hpp:299 — securityClientKsPath + public string SecurityClientKsPath { get; init; } = string.Empty; + + // SystemProperties.cpp:80 — DefaultDurableClientId = "" + public string DurableClientId { get; init; } = string.Empty; + + // SystemProperties.cpp:81 — DefaultDurableTimeout = seconds(300) + public TimeSpan DurableTimeout { get; init; } = TimeSpan.FromSeconds(300); + + // SystemProperties.cpp:83 — DefaultConnectTimeout = seconds(59) + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(59); + + // SystemProperties.cpp:84 — DefaultConnectWaitTimeout = zero (Linux only) + public TimeSpan ConnectWaitTimeout { get; init; } = TimeSpan.Zero; + + // SystemProperties.cpp:85 — DefaultBucketWaitTimeout = zero (Linux only) + public TimeSpan BucketWaitTimeout { get; init; } = TimeSpan.Zero; + + // SystemProperties.cpp:98 — DefaultAutoReadyForEvents = true + public bool AutoReadyForEvents { get; init; } = true; + + // SystemProperties.cpp:99 — DefaultSslEnabled = false + public bool SslEnabled { get; init; } + + // SystemProperties.cpp:100 — DefaultTimeStatisticsEnabled = false + public bool TimeStatisticsEnabled { get; init; } + + // SystemProperties.cpp:102 — DefaultSslKeyStore = "" + public string SslKeyStore { get; init; } = string.Empty; + + // SystemProperties.cpp:103 — DefaultSslTrustStore = "" + public string SslTrustStore { get; init; } = string.Empty; + + // SystemProperties.cpp:104 — DefaultSslKeystorePassword = "" + public string SslKeystorePassword { get; init; } = string.Empty; + + // SystemProperties.cpp:78 — DefaultConflateEvents = "server" + public string ConflateEvents { get; init; } = "server"; + + // SystemProperties.cpp:121 — DefaultThreadPoolSize = hardware_concurrency * 2 + public uint ThreadPoolSize { get; init; } = (uint)(Environment.ProcessorCount * 2); + + // SystemProperties.cpp:122 — DefaultSuspendedTxTimeout = seconds(30) + public TimeSpan SuspendedTxTimeout { get; init; } = TimeSpan.FromSeconds(30); + + // SystemProperties.cpp:123 — DefaultTombstoneTimeout = seconds(480) + public TimeSpan TombstoneTimeout { get; init; } = TimeSpan.FromSeconds(480); + + // SystemProperties.cpp:125 — DefaultEnableChunkHandlerThread = false + public bool EnableChunkHandlerThread { get; init; } + + // SystemProperties.cpp:126 — DefaultOnClientDisconnectClearPdxTypeIds = false + public bool OnClientDisconnectClearPdxTypeIds { get; init; } + + /// + /// Maximum nested-container depth allowed when serialising or + /// deserialising wire payloads. Default 64 (matches + /// ). + /// Must be >= 1; validated at host build time by + /// GeodeClientOptionsValidator. + /// + public int MaxDepth { get; set; } = 64; + + /// + /// Maximum element count for any single length-prefixed wire + /// payload — [], primitive arrays + /// ([] / [] / + /// …), CacheableObjectArray / CacheableStringArray, + /// and the Tier B-2 collection types + /// (List<T> / HashSet<T> / + /// Dictionary<K,V> / LinkedList<T> / + /// Stack<T>). Default 1,000,000. + /// + /// + /// + /// Threat model. A hostile or buggy server can claim any + /// int32 length in the wire-length prefix. Without a cap, + /// the client reads "I will give you 2,000,000,000 elements" and + /// allocates 8 GB up front — instant OOM. Capping the + /// length on read forces a fast, clear failure + /// (GeodeException) instead. + /// + /// + /// Inclusive bound. The check is length > MaxArrayLength + /// — a payload claiming exactly + /// elements is accepted; one element more is rejected. 0 + /// is the unit-of-measure (allow only empty arrays); + /// rejects negative + /// values. + /// + /// + /// Default rationale. Geode best practice is to keep a + /// single cached value under ~1 MB. A million elements + /// covers int[] up to 4 MB and byte[] up to + /// 1 MB — comfortably above realistic workloads while + /// rejecting hostile gigabyte-scale claims. Tune up for legitimate + /// bulk-data use cases. + /// + /// + /// Write side applies the same cap (caller bug guard) — + /// crossing the limit on encode throws + /// ; on decode it throws + /// GeodeException. Symmetric so we never emit a payload + /// our own reader would refuse. + /// + /// + public int MaxArrayLength { get; set; } = 1_000_000; + + /// + /// Maximum byte count for a single [] + /// payload ( = 46). Default + /// 10,000,000 (10 MB). + /// + /// + /// + /// Split out from because + /// [] serves a different role on Geode: + /// it's the canonical "binary blob" type — file content, + /// serialised objects from another stack, encrypted payloads — + /// which has a wholly different natural size distribution from + /// "a primitive array with many small elements". Typical blob + /// caches range from hundreds of KB to a few MB; the + /// default of 1 M would + /// gratuitously reject those. Tune up to 100 MB or down to + /// 1 MB depending on the workload. + /// + /// + /// Same threat-model and check semantics as + /// : hostile servers can claim any + /// int32 length in the VL-encoded prefix; on read we + /// refuse before allocating. Inclusive bound (length > MaxBytesLength); + /// 0 legal; negative rejected at host build time. + /// + /// + public int MaxBytesLength { get; set; } = 10_000_000; + + /// + /// Maximum length for any single string payload — covers all four + /// CacheableString wire variants (DSCodes 42 / 87 / 88 / + /// 89). Unit is whatever the wire format puts in the length + /// prefix for that variant (modified-UTF-8 byte count for 42, + /// char count for the other three). Default 1,000,000. + /// + /// + /// + /// Threat model matches : the + /// "huge" string variants (88 / 89) carry an i32 length prefix + /// which a hostile server can pin at 2 billion, forcing a + /// multi-gigabyte allocation. Cap on read prevents that. + /// + /// + /// Why a separate limit from . + /// Strings and bulk-data arrays have different "natural" size + /// distributions — a 1 MB JSON-shaped string is not unusual, + /// while a 1 MB-element collection is. Keeping the two + /// configurable independently lets users tune one without + /// loosening the other. Same default for now (1 M) so a stock + /// install behaves consistently. + /// + /// + /// Inclusive bound and validation follow the same rules as + /// : length > MaxStringLength + /// fails; 0 is legal (empty strings only); negative + /// rejected at host build time. + /// + /// + public int MaxStringLength { get; set; } = 1_000_000; +} diff --git a/src/Geode.Client/Internal/TcrConnectionManager.cs b/src/Geode.Client/Internal/TcrConnectionManager.cs new file mode 100644 index 0000000..99e412a --- /dev/null +++ b/src/Geode.Client/Internal/TcrConnectionManager.cs @@ -0,0 +1,351 @@ +using System.Collections.Concurrent; +using System.Net; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +internal sealed class TcrConnectionManager( + IServiceProvider serviceProvider, + ILogger logger, + GeodeCache cache) +{ + /// + /// 0 = not run, 1 = ran. + /// Mirrors cppcache m_initGuard; gated by + /// for + /// idempotency. + /// + private int _initGuard; + private bool _isDurable; + private readonly ConcurrentDictionary> _endpoints = new(); + + public Task InitAsync(bool isPool, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + // Idempotent (cppcache m_initGuard). First caller wins; later + // calls are a no-op even with a different `isPool` argument — + // matches cppcache, which only honours the first init's mode. + if (Interlocked.Exchange(ref _initGuard, 1) != 0) + { + return Task.CompletedTask; + } + + // Pool mode keepalive lives in ThinClientPoolDM, so the only + // thing this branch does is publish the durable flag for + // anyone who later reads IsDurable / haEnabled. Non-pool mode + // additionally launches three background workers + the ping + // PeriodicTimer (Phase 2+). + Volatile.Write( + ref _isDurable, + !string.IsNullOrEmpty(cache.CacheProperties.DurableClientId)); + + if (!isPool) + { + // TODO Phase 2+: start _failoverTask / _cleanupTask / + // _redundancyTask, schedule the ping PeriodicTimer. + throw new NotImplementedException("TODO: non-pool TcrConnectionManager.InitAsync (Phase 2+)"); + } + + return Task.CompletedTask; + } + + /// + /// Get-or-create the cache-wide for + /// and bump its reference count. + /// Mirrors cppcache + /// TcrConnectionManager::addRefToTcrEndpoint + /// (TcrConnectionManager.cpp:200-221). + /// + /// Endpoint key, formatted as "host:port". + /// + /// The distribution manager taking the reference. cppcache stores + /// this in TcrEndpoint::m_baseDM; it's used by the + /// subscription channel (Phase 2+) and the failover signal path + /// (Phase 1.5). + /// + /// + /// The shared instance — one per + /// unique "host:port" across the whole cache, even when + /// referenced by multiple pools / regions. + /// + /// + /// cppcache locks the whole map for the get-or-create + ref-count + /// bump. In .NET the cheaper idiom is + /// ConcurrentDictionary.GetOrAdd with a + /// Lazy<TcrEndpoint> value-factory + /// (LazyThreadSafetyMode.ExecutionAndPublication) so the + /// race-loser doesn't construct a throwaway endpoint; the + /// NumRegions++ bump then happens on the winning instance. + /// + public async Task AddRefToTcrEndpointAsync( + DnsEndPoint endpointAddress, + ThinClientBaseDM dm, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(endpointAddress); + ArgumentNullException.ThrowIfNull(dm); + ct.ThrowIfCancellationRequested(); + + // 1. Get-or-create under Lazy so only the winning ctor actually + // instantiates a TcrEndpoint; race losers reuse the winner's + // instance via LazyThreadSafetyMode.ExecutionAndPublication. + // ActivatorUtilities lets TcrEndpoint pull its non-positional + // deps (ILogger, etc.) from DI directly — TCCM + // forwards `endpointAddress` positionally. + // Capture `this` so the value-factory can hand TCCM back to the + // endpoint ctor — gives endpoint a direct back-ref instead of + // routing every TCCM-bound op through the cache hop. + var tccm = this; + var lazy = _endpoints.GetOrAdd( + endpointAddress, + ep => new Lazy( + () => ActivatorUtilities.CreateInstance(serviceProvider, ep, tccm), + LazyThreadSafetyMode.ExecutionAndPublication)); + + // 2. Force the ctor (winner constructs; subsequent callers + // hit the cached value). + var endpoint = lazy.Value; + + // 3. Atomic ref-count bump. cppcache holds the map lock across + // new + setNumRegions; we hoist the bump out of the GetOrAdd + // critical section by making it interlocked instead. + var refs = endpoint.IncrementNumRegions(); + + logger.LogTrace( + "TCCM: incremented region reference count for endpoint {Endpoint} to {Refs}", + endpoint.Name, refs); + + // 4. Register dm into endpoint._distMgrs (Phase 1.5 failover + // broadcast list). cppcache passes dm into TcrEndpoint ctor + // as m_baseDM; we instead route it through registerDM (option + // B) so pool / non-pool / multi-DM cases share one path. + await endpoint.RegisterDMAsync( + clientNotification: false, + isSecondary: false, + isActiveEndpoint: false, + distributionManager: dm, + ct: ct).ConfigureAwait(false); + + return endpoint; + } +} + +/* +using System.Collections.Concurrent; +using System.Net; +using System.Threading.Channels; +using Geode.Client.Internal; +using Geode.Client.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Owns the live TCP/TLS endpoint connections and the background +/// orchestration that keeps them healthy (failover, cleanup, HA +/// redundancy, periodic ping). Mirrors cppcache +/// TcrConnectionManager +/// (cppcache/src/TcrConnectionManager.hpp/.cpp). +/// +/// +/// +/// Heaviest member of : +/// the only one that spawns its own threads. cppcache gates the +/// three background workers (failover / cleanup / redundancy) and +/// the ping schedule by if (!isPool) — pool-mode +/// caches push that work into ThinClientPoolDM instead. Our +/// MVP runs pool-only, so most fields below stay null until a +/// non-pool / HA / CQ phase. +/// +/// +/// Member fields mirror cppcache TcrConnectionManager.hpp +/// 1:1 per CLAUDE.md "mirror then prune". Owning types not built +/// yet are typed as object? placeholders — replace +/// with the real type when its phase ships, or delete if never +/// used. The cppcache back-pointer m_cache is omitted +/// (Pimpl collapsed; this class lives as a field on +/// Cache and gets options through DI). +/// +/// +internal sealed class TcrConnectionManager( + CacheScopeContext scopeContext, + ILogger logger, + IServiceProvider serviceProvider) : IAsyncDisposable +{ + private readonly GeodeClientOptions _options = scopeContext.Options; + private readonly ILogger _logger = logger; + private readonly IServiceProvider _serviceProvider = serviceProvider; + +#pragma warning disable CS0169, CS0414, CS0649, CS9113 // placeholder fields mirroring TcrConnectionManager; wired up phase by phase + + + + // ── Distribution-manager registry (m_distMngrs) ── + private readonly List _distributionManagers = new(); // m_distMngrs (value: ThinClientBaseDM) + private readonly ReaderWriterLockSlim _distributionManagersLock = new(); + + // ── Background workers (m_failoverTask / m_cleanupTask / m_redundancyTask) ── + // cppcache: three unique_ptr + binary_semaphore each. + // .NET: Task + SemaphoreSlim. All null until InitAsync(isPool: false). + private Task? _failoverTask; // m_failoverTask + private Task? _cleanupTask; // m_cleanupTask + private Task? _redundancyTask; // m_redundancyTask + private readonly SemaphoreSlim _failoverSignal = new(0, int.MaxValue); // failover_semaphore_ + private readonly SemaphoreSlim _cleanupSignal = new(0, int.MaxValue); // cleanup_semaphore_ + private readonly SemaphoreSlim _redundancySignal = new(0, int.MaxValue); // redundancy_semaphore_ + private readonly CancellationTokenSource _backgroundCts = new(); // unify shutdown + + // ── Periodic ping (cppcache ping_task_id_ via ExpiryTaskManager) ── + private PeriodicTimer? _pingTimer; // bucket-1 replacement + private Task? _pingLoop; + + // ── HA subscription / redundancy (m_redundancyManager) ── + private object? _redundancyManager; // ThinClientRedundancyManager (Phase 2+) + + // ── Runtime flags (m_isDurable / m_isNetDown) ── + + private int _isNetDown; // m_isNetDown (Interlocked 0/1) + + // ── Deferred cleanup queues ── + // cppcache: Queue, Queue, Queue + private Channel? _connectionReleaseQueue; // m_connectionReleaseList + private Channel? _receiverReleaseQueue; // m_receiverReleaseList + private Channel? _notifyCleanupSemaphoreQueue; // notify_cleanup_semaphore_list_ + + // ── Disposal flag ── + private int _disposed; + +#pragma warning restore CS0169, CS0414, CS0649 + + + + public bool IsDurable => Volatile.Read(ref _isDurable); + + public bool IsHaEnabled => _redundancyManager is not null; + + public bool IsNetDown => Volatile.Read(ref _isNetDown) != 0; + + /// + /// Snapshot of registered endpoints. Mirrors cppcache + /// TcrConnectionManager::getGlobalEndpoints(). Lazy entries + /// are materialised on iteration — safe because by the + /// time an entry is in the map, + /// has already forced + /// Lazy.Value at least once. + /// + public IReadOnlyDictionary GetGlobalEndpoints() + => _endpoints.ToDictionary( + static kv => kv.Key, + static kv => kv.Value.Value); + + + + + + /// + /// Register a distribution manager with a set of endpoints. + /// Mirrors cppcache + /// TcrConnectionManager::connect(dm, endpoints, endpointStrs). + /// + /// + /// Owning distribution manager — typed as object until + /// ThinClientBaseDM lands. + /// + /// + /// Resolved endpoint instances — typed as object until + /// TcrEndpoint lands. + /// + public Task ConnectAsync( + object distributionManager, + IReadOnlyList endpoints, + IReadOnlyList endpointStrs, + CancellationToken ct = default) + { + // TODO: lookup or create TcrEndpoint per endpointStr in _endpoints; + // register dm into _distributionManagers under the rwlock. + throw new NotImplementedException("TODO: TcrConnectionManager.ConnectAsync"); + } + + /// + /// Unregister a distribution manager. Mirrors cppcache + /// TcrConnectionManager::disconnect(dm, endpoints, keepEndpoints). + /// + public Task DisconnectAsync( + object distributionManager, + IReadOnlyList endpoints, + bool keepEndpoints, + CancellationToken ct = default) + { + // TODO: drop dm from _distributionManagers; for each endpoint with + // no remaining users and !keepEndpoints, remove from + // _endpoints and queue for cleanup. + throw new NotImplementedException("TODO: TcrConnectionManager.DisconnectAsync"); + } + + /// + /// Ping every connected endpoint once. Driven by the + /// in non-pool mode; mirrors cppcache + /// TcrConnectionManager::ping_endpoints(). + /// + public Task PingEndpointsAsync(CancellationToken ct = default) + { + // TODO: foreach endpoint in _endpoints → endpoint.SendPingAsync(ct). + throw new NotImplementedException("TODO: TcrConnectionManager.PingEndpointsAsync"); + } + + /// + /// Stop background workers and cancel pending tasks. Mirrors + /// cppcache TcrConnectionManager::close(). Does **not** + /// release endpoint objects — + /// handles final teardown. + /// + public Task CloseAsync(CancellationToken ct = default) + { + // TODO: dispose _pingTimer, signal _backgroundCts, await + // _failoverTask / _cleanupTask / _redundancyTask / + // _pingLoop, drain release queues. + throw new NotImplementedException("TODO: TcrConnectionManager.CloseAsync"); + } + + /// + /// Test hook: simulate a network outage. Mirrors cppcache + /// TcrConnectionManager::netDown(). + /// + public void NetDown() + { + // TODO: Interlocked.Exchange(ref _isNetDown, 1) + + // force-disconnect every endpoint. + throw new NotImplementedException("TODO: TcrConnectionManager.NetDown"); + } + + /// + /// Test hook: revive after . Mirrors cppcache + /// TcrConnectionManager::revive(). + /// + public void Revive() + { + // TODO: Interlocked.Exchange(ref _isNetDown, 0) + signal + // _failoverSignal so endpoints reconnect. + throw new NotImplementedException("TODO: TcrConnectionManager.Revive"); + } + + public ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return ValueTask.CompletedTask; + + _backgroundCts.Cancel(); + _pingTimer?.Dispose(); + _failoverSignal.Dispose(); + _cleanupSignal.Dispose(); + _redundancySignal.Dispose(); + _backgroundCts.Dispose(); + _distributionManagersLock.Dispose(); + + // TODO: await loop tasks before returning; drain queues; dispose endpoints. + return ValueTask.CompletedTask; + } +} + +*/ diff --git a/src/Geode.Client/Internal/TcrEndpoint.cs b/src/Geode.Client/Internal/TcrEndpoint.cs new file mode 100644 index 0000000..6e37f99 --- /dev/null +++ b/src/Geode.Client/Internal/TcrEndpoint.cs @@ -0,0 +1,554 @@ +using System.Net; +using Geode.Client.Options; +using Geode.Client.Protocol; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Single Geode server endpoint. Owns this server's per-endpoint +/// connection pool, the dedicated subscription channel (non-pool / +/// HA only), authentication token, and health flags. Mirrors cppcache +/// TcrEndpoint +/// (cppcache/src/TcrEndpoint.hpp/.cpp). +/// +/// +/// +/// One instance per host:port; held in +/// 's endpoint registry. Created on +/// first reference (cppcache TcrConnectionManager::addRefToTcrEndpoint). +/// +/// +/// MVP (pool mode) only needs: per-endpoint connection pool, +/// connected_ flag, m_uniqueId auth token, and +/// send / createNewConnection / pingServer. +/// Subscription channel + redundancy + multi-user auth + HA queue +/// state are all Phase 2+. +/// +/// +/// Inheritance: base for the cppcache TcrEndpoint / +/// TcrPoolEndPoint split. Pool-mode endpoints should be +/// instantiated as ; this base is reserved +/// for non-pool / legacy paths (deferred per memory +/// pool-only-no-non-pool.md). Migration of pool-only state +/// (per-pool DM ref, getPoolHADM-style accessor) to the +/// subclass is in progress — see PORTING.md. +/// +/// +internal class TcrEndpoint( + IServiceProvider serviceProvider, + ILogger logger, + DnsEndPoint endpoint, + TcrConnectionManager connectionManager) //: IAsyncDisposable +{ + /// + /// Owning . Mirrors cppcache's + /// TcrEndpoint::m_cacheImpl->tcrConnectionManager() + /// indirection (we collapse the hop because TCCM is the layer that + /// actually owns this endpoint registry). + /// + internal TcrConnectionManager ConnectionManager => connectionManager; + + /// + /// connected_ (atomic → Interlocked 0/1) + /// + private int _connected; + private int _numRegions; + // private int _disposed; + + /// + /// DMs that have registered interest in this endpoint via + /// . Mirrors cppcache m_distMgrs + /// (TcrEndpoint.hpp:213). Used as the broadcast list for + /// endpoint-wide state transitions (e.g. + /// fans out Inc/DecConnectedEndpoints to every DM here); + /// cppcache simplifies by notifying only m_baseDM, but our + /// list-walk handles multi-pool endpoint sharing correctly. All + /// access guarded by . + /// + private readonly List _distMgrs = []; + + /// + /// Guards . Mirrors cppcache + /// m_distMgrsLock (TcrEndpoint.hpp:215). Held during + /// register / unregister and during transition broadcasts so a DM + /// can't be dropped mid-iteration. + /// + private readonly Lock _distMgrsLock = new(); + + // /// + // /// cppcache m_maxConnections — per-endpoint conn cap from + // /// . 0 = unlimited + // /// (our re-interpretation; cppcache's 0 is a separate "lazy + // /// single conn" mode we don't port). + // /// + // private readonly int _maxConnections = cacheScopeContext.Options.Pool.ConnectionPoolSize; + + private bool _msgSent; + + private readonly SemaphoreSlim _notificationCleanupSignal = new(0, int.MaxValue); + private bool _pingSent; + private int _pingTimeouts; + + /// + /// Slot semaphore enforcing . Null when + /// is 0 (unlimited). + /// + private readonly SemaphoreSlim? _slots = MakeSlotSemaphore(5); // todo + + private static SemaphoreSlim? MakeSlotSemaphore(int size) => + size > 0 ? new SemaphoreSlim(size, size) : null; + + /// + /// Reserve one of this endpoint's slots, + /// waiting up to . Returns false if + /// the cap is hit and the wait expires; true when a slot is + /// acquired (caller must on conn close) or + /// the endpoint is in unlimited mode. + /// + internal async ValueTask AcquireSlotAsync(TimeSpan timeout, CancellationToken ct) + { + if (_slots is null) return true; + return await _slots.WaitAsync(timeout, ct).ConfigureAwait(false); + } + + /// + /// Atomically increment the region / DM reference count. Mirrors + /// cppcache setNumRegions(numRegions() + 1) performed inside + /// TcrConnectionManager::addRefToTcrEndpoint; we hoist the + /// +1 into a dedicated method so the bump is atomic without + /// holding the map lock. + /// + /// The new reference count. + internal int IncrementNumRegions() => Interlocked.Increment(ref _numRegions); + + /// + /// Release a slot reserved via . + /// + internal void ReleaseSlot() => _slots?.Release(); + + // /// + // /// Run the auth handshake on a freshly-opened connection. Mirrors + // /// cppcache TcrEndpoint::authenticateEndpoint. + // /// + // public Task AuthenticateEndpointAsync(object connection, CancellationToken ct = default) + // { + // // TODO Phase 3 (security): send credentials, read uniqueId. + // throw new NotImplementedException("TODO: TcrEndpoint.AuthenticateEndpointAsync"); + // } + + /// + /// Open a fresh TCP/TLS connection and run the handshake. Mirrors + /// cppcache TcrEndpoint::createNewConnection. Linux-only + /// retry-under-lock variant createNewConnectionWL is bucket + /// 1 (modern .NET sockets don't need it). + /// + public async Task CreateNewConnectionAsync( + ThinClientPoolDM pool, + bool isClientNotification, + bool isSecondary, + TimeSpan? connectTimeout = null, + CancellationToken ct = default) + { + if (isClientNotification) + { + // cppcache: HandShake.cpp builds a different wire format for + // notification channels (port list, no read-timeout). Our + // TcrConnection.HandshakeAsync still throws NIE on that branch + // (Phase 2+ subscription / CQ). + throw new NotImplementedException("TODO Phase 2+: notification-channel handshake."); + } + _ = isSecondary; // only meaningful with isClientNotification. + + ct.ThrowIfCancellationRequested(); + + logger.LogDebug("TcrEndpoint.CreateNewConnection: opening request/response connection to {Host}:{Port}", + endpoint.Host, endpoint.Port); + + // Pull TcrConnection through DI so its own deps (ILogger, + // IOptions, ClientProxyMembershipIdBuilder) + // resolve cleanly; `this` (Endpoint) + `pool` ride as positional + // args so the conn carries both its target server identity and + // its owning pool from ctor onwards. + var conn = ActivatorUtilities.CreateInstance(serviceProvider, this, pool); + + try + { + // ConnectAsync bundles TCP connect (Nagle off) + the full + // client/server handshake (steps 1-14). Mirrors cppcache + // initTcrConnection: success or throw, no half-states. + // • GeodeException — server refused the handshake (REPLY_OK + // not received) or pointed at a locator port. + // • SocketException / IOException — TCP failure. + // • OperationCanceledException — ct cancelled. + await conn.ConnectAsync(endpoint.Host, endpoint.Port, connectTimeout, ct).ConfigureAwait(false); + + // Endpoint state flags are caller-driven (mirror cppcache): + // • SetConnected — ThinClientPoolDM::createPoolConnection + // (pool path) / TcrEndpoint::pingServer (probe path). + // • _isAuthenticated — set by authenticateEndpoint in + // Phase 3 (security mode != NONE). NONE leaves it false. + return conn; + } + catch + { + // Don't leak a half-opened conn. cppcache: _GEODE_SAFE_DELETE(newConn) + // at the bottom of createNewConnection when err != GF_NOERR. + await conn.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + // public ValueTask DisposeAsync() + // { + // if (Interlocked.Exchange(ref _disposed, 1) != 0) return ValueTask.CompletedTask; + + // _connectLock.Dispose(); + // _notificationCleanupSignal.Dispose(); + // _slots?.Dispose(); + + // // TODO: close _opConnections, _notifyConnection, await + // // _notifyReceiver task; release endpoint resources. + // return ValueTask.CompletedTask; + // } + + /// + /// Send MessageType.Ping through and + /// update based on the reply. Mirrors cppcache + /// TcrEndpoint::pingServer(ThinClientPoolDM*) + /// (cppcache/src/TcrEndpoint.cpp:499-544). + /// + /// + /// + /// cppcache passes poolDM == nullptr for non-pool / standalone + /// endpoints and falls back to this->send(pingMsg, reply); we + /// only run pool-mode in MVP so the null branch throws NIE for now. + /// + /// + /// The cppcache m_msgSent / m_pingSent short-circuit + /// (TcrEndpoint.cpp:506,540-543) is preserved verbatim — every + /// other tick is a no-op when there has been recent activity, halving + /// ping bandwidth when the channel is busy. Phase 1.2's + /// SendSyncRequestAsync will set _msgSent = true after + /// each real op so this skip starts paying off; until then + /// _msgSent stays false and only _pingSent gates the + /// skip (effective ping cadence = every 2 ticks). + /// + /// + /// cppcache's GF_TIMEOUT tolerance (m_pingTimeouts < 2 + /// before flipping connected_) is deferred to Phase 1.5 — needs + /// the GfErrType taxonomy to land first so we can distinguish + /// "transport timed out" from "server returned exception" cleanly. For + /// now any send-path exception flips connected to false. + /// + /// + public async Task PingAsync( + ThinClientPoolDM? poolDM = null, + CancellationToken ct = default) + { + logger.LogDebug("Sending ping message to endpoint {Endpoint}", Name); + + if (!IsConnected) + { + logger.LogTrace("Skipping ping task for disconnected endpoint {Endpoint}", Name); + return; + } + + // Activity short-circuit (cppcache L506,540-543). + if (_msgSent || _pingSent) + { + _msgSent = false; + _pingSent = false; + return; + } + + if (poolDM is null) + { + // cppcache TcrEndpoint.cpp:514-516 falls back to this->send(...). + // Standalone / non-pool DM is Phase 2+. + throw new NotImplementedException("TODO Phase 2+: standalone endpoint.send(ping) path (non-pool DM)."); + } + + var messageBuilder = TcrMessageBuilder.Create(serviceProvider, MessageType.Ping); + var pingRequest = await messageBuilder.BuildAsync(ct); + + logger.LogTrace("Sending ping message to endpoint {Endpoint}", Name); + + TcrMessage reply; + try + { + reply = await poolDM + .SendRequestToEndpointAsync(pingRequest, this, ct) + .ConfigureAwait(false); + _pingSent = true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Caller-driven shutdown — propagate; loop layer treats as graceful. + throw; + } + catch (Exception ex) + { + // TODO Phase 1.5: classify as GF_TIMEOUT and tolerate up to 2 + // consecutive timeouts (++_pingTimeouts) before flipping + // connected. cppcache TcrEndpoint.cpp:522-524. + // Currently any error flips connected immediately. + _pingTimeouts = 0; + logger.LogWarning(ex, "Ping to endpoint {Endpoint} failed; marking disconnected", Name); + if (IsConnected) + { + SetConnected(false); + } + return; + } + + // Non-timeout outcome → reset tolerance counter (cppcache L525). + _pingTimeouts = 0; + + // cppcache (TcrEndpoint.cpp:532-534): connected iff the server + // returned a proper Reply frame. Anything else (Exception reply, + // unexpected MessageType) means the server is unhappy with us. + var connected = reply.MessageType == MessageType.Reply; + if (IsConnected != connected) + { + SetConnected(connected); + } + + logger.LogTrace("Completed sending ping message to endpoint {Endpoint} (replyType={ReplyType})", + Name, reply.MessageType); + } + + // /// + // /// Receiver loop body for the subscription channel; mirrors + // /// cppcache TcrEndpoint::receiveNotification. Drives event + // /// dispatch to registered listeners. + // /// + // public Task ReceiveNotificationsAsync(CancellationToken ct = default) + // { + // // TODO Phase 2+: blocking read on _notifyConnection, decode + // // message, dispatch to ThinClientRegion listeners. Phase 2+. + // throw new NotImplementedException("TODO: TcrEndpoint.ReceiveNotificationsAsync"); + // } + + /// + /// Register a DM as a user of this endpoint; opens the dedicated + /// subscription connection if + /// and not already running. Mirrors cppcache + /// TcrEndpoint::registerDM. + /// + /// + /// cppcache bundles three concerns; we implement them per phase: + /// (1) bind dm into _distMgrs — Phase 1.1 (used by + /// Phase 1.5's failover broadcast: a dying endpoint signals every + /// DM in this list to re-route); + /// (2) open notification connection + receiver Task — + /// Phase 2+ (subscription / CQ / register-interest); + /// (3) flip _isActiveEndpoint for redundancy manager — + /// Phase 2+ (HA). + /// + public Task RegisterDMAsync( + bool clientNotification, + bool isSecondary, + bool isActiveEndpoint, + ThinClientBaseDM? distributionManager = null, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (clientNotification) + { + throw new NotImplementedException( + "TODO Phase 2+: subscription / notification channel."); + } + if (isActiveEndpoint) + { + throw new NotImplementedException( + "TODO Phase 2+: redundancy / active endpoint flag."); + } + _ = isSecondary; // only meaningful when clientNotification. + + if (distributionManager is null) + { + return Task.FromResult(0); + } + + // Dedupe under the lock so repeated AddRefToTcrEndpoint calls + // from the same pool don't multiply the broadcast list. + lock (_distMgrsLock) + { + if (!_distMgrs.Contains(distributionManager)) + { + _distMgrs.Add(distributionManager); + } + } + + return Task.FromResult(0); + } + + // /// + // /// Send a request and wait for reply, choosing a connection from + // /// _opConnections. Mirrors cppcache + // /// TcrEndpoint::send(request, reply). + // /// + // public Task SendAsync( + // object request, // TcrMessage + // object reply, // TcrMessageReply + // CancellationToken ct = default) + // { + // // TODO: dequeue from _opConnections; conn.Send(request, reply); + // // enqueue back; on error → CloseFailedConnection + + // // set _connected = 0 + signal _failoverSignal. + // throw new NotImplementedException("TODO: TcrEndpoint.SendAsync"); + // } + + // /// + // /// Send with retries against this endpoint's pool. Mirrors cppcache + // /// TcrEndpoint::sendRequestWithRetry. + // /// + // public Task SendRequestWithRetryAsync( + // object request, + // object reply, + // int maxSendRetries, + // CancellationToken ct = default) + // { + // throw new NotImplementedException("TODO: TcrEndpoint.SendRequestWithRetryAsync"); + // } + + /// + /// Flip and, on a real 0↔1 transition, + /// broadcast Inc/DecConnectedEndpoints to every DM in + /// . Mirrors cppcache + /// TcrEndpoint::setConnected / setConnectionStatus + /// (TcrEndpoint.cpp:1114-1123) — cppcache uses + /// compare_exchange_strong to gate the inc/dec on a real flip; + /// we use + /// for the same effect. Same-value writes are silent no-ops. + /// + /// + /// Divergence from cppcache: cppcache notifies a single m_baseDM; + /// we walk so multi-pool endpoint sharing + /// (legal in our TCCM design) sees the transition on every interested + /// DM. Callees ( / + /// ) must stay + /// lock-free and non-reentrant w.r.t. this endpoint — they run under + /// . + /// + public void SetConnected(bool connected) + { + var newVal = connected ? 1 : 0; + var oldVal = connected ? 0 : 1; + if (Interlocked.CompareExchange(ref _connected, newVal, oldVal) != oldVal) + { + // Same-value write, or another thread won the flip race. + return; + } + lock (_distMgrsLock) + { + foreach (var dm in _distMgrs) + { + if (connected) dm.IncConnectedEndpoints(); + else dm.DecConnectedEndpoints(); + } + } + } + + // /// + // /// Drop a DM. Mirrors cppcache TcrEndpoint::unregisterDM. + // /// When the last DM leaves and notification was started, close + // /// the subscription connection. + // /// + // public Task UnregisterDMAsync( + // bool clientNotification, + // object? distributionManager = null, + // CancellationToken ct = default) + // { + // // TODO: drop dm from _distMgrs; if last + clientNotification, + // // stopNotifyReceiverAndCleanup. + // throw new NotImplementedException("TODO: TcrEndpoint.UnregisterDMAsync"); + // } + + // public DnsEndPoint Endpoint => endpoint; + + // public bool IsAuthenticated => _isAuthenticated; + + public bool IsConnected => Volatile.Read(ref _connected) != 0; + + /// Canonical "host:port" rendering for logs / registry keys. + public string Name => $"{endpoint.Host}:{endpoint.Port}"; + + public int NumRegions + { + get => Volatile.Read(ref _numRegions); + set => Volatile.Write(ref _numRegions, value); + } + + // public long UniqueId => Interlocked.Read(ref _uniqueId); + + //#pragma warning disable CS0169, CS0414, CS0649 // placeholder fields mirroring TcrEndpoint; wired up phase by phase + + // // ── Per-endpoint connection pool (TcrEndpoint.hpp:185-188) ── + // private object? _opConnections; // m_opConnections (ConnectionQueue) + + + // private bool _needToConnectInLock; // m_needToConnectInLock + // private bool _connCreatedWhenMaxConnsIsZero; // m_connCreatedWhenMaxConnsIsZero + + + + // // ── Subscription channel (Phase 2+; TcrEndpoint.hpp:178-184) ── + // private object? _notifyConnection; // m_notifyConnection (TcrConnection*) + // private Task? _notifyReceiver; // m_notifyReceiver (Task) + // private readonly List _notifyReceiverList = []; // m_notifyReceiverList + // private readonly List _notifyConnectionList = []; // m_notifyConnectionList + + // // ── DM registration (TcrEndpoint.hpp:211-216) ── + // // Pool mode (option B in design notes) routes DMs through _distMgrs + // // only — m_baseDM stays unused. Non-pool mode (Phase 2+) may revive + // // m_baseDM as a back-pointer to the owning region's DM. + // private object? _baseDM; // m_baseDM (ThinClientBaseDM*) — non-pool only + + // private readonly Lock _connectionLock = new(); + // private readonly SemaphoreSlim _connectLock = new(1, 1); // m_connectLock (timed_mutex; .NET uses await with timeout) + // private readonly Lock _notifyReceiverLock = new(); + // private readonly Lock _endpointAuthenticationLock = new(); + + // // ── Health (TcrEndpoint.hpp:219-228) ── + + + + + // // ── Auth (TcrEndpoint.hpp:207, 224, 227) ── + // private bool _isAuthenticated; // m_isAuthenticated + // private long _uniqueId; // m_uniqueId (server-issued auth token, set after handshake) + // private bool _isMultiUserMode; // m_isMultiUserMode (Phase 3) + + // // ── HA / queue state (TcrEndpoint.hpp:189, 229-234) ── + // private bool _isQueueHosted; // m_isQueueHosted + // private bool _isActiveEndpoint; // m_isActiveEndpoint + // private int _serverQueueStatus; // m_serverQueueStatus (enum ServerQueueStatus) + // private int _queueSize; // m_queueSize + // private bool _isServerQueueStatusSet; // m_isServerQueueStatusSet + // private ushort _distributedMemId; // m_distributedMemId + + // // ── Counters (TcrEndpoint.hpp:187, 220-223) ── + // private int _numRegionListener; // m_numRegionListener + // private int _numRegions; // m_numRegions + // private int _notifyCount; // m_notifyCount + // private uint _dupCount; // m_dupCount + + // // ── TCCM coordination semaphores (TcrEndpoint.hpp:208-210, 217) ── + // // cppcache passes binary_semaphore& from TCCM into the endpoint ctor; + // // .NET takes them as ctor refs (or via DI) when TCCM truly drives them. + // private SemaphoreSlim? _failoverSignal; // failover_semaphore_ + // private SemaphoreSlim? _cleanupSignal; // cleanup_semaphore_ + // private SemaphoreSlim? _redundancySignal; // redundancy_semaphore_ + + + + + //#pragma warning restore CS0169, CS0414, CS0649 + + +} diff --git a/src/Geode.Client/Internal/TcrPoolEndPoint.cs b/src/Geode.Client/Internal/TcrPoolEndPoint.cs new file mode 100644 index 0000000..77610c8 --- /dev/null +++ b/src/Geode.Client/Internal/TcrPoolEndPoint.cs @@ -0,0 +1,55 @@ +using System.Net; +using Geode.Client.Internal; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Pool-mode endpoint subclass. Mirrors cppcache +/// TcrPoolEndPoint +/// (cppcache/src/TcrPoolEndPoint.hpp/.cpp) — the variant of +/// that owns a single +/// back-reference and routes pool-mode +/// concerns (per-pool DM ref via getPoolHADM, pool-mode +/// registerDM, pool-aware handleIOException, +/// handleNotificationStats) through that ref. +/// +/// +/// +/// Phase 1.5 skeleton: empty body. Exists so the cppcache +/// inheritance shape TcrEndpointTcrPoolEndPoint +/// is present from the start; concrete migrations land step by step: +/// +/// +/// m_dm field + getPoolHADM() +/// accessor — pull the pool ref out of the base class's +/// _distMgrs list (which currently doubles as the multi-pool +/// broadcast list because the base has no single-DM concept) +/// once pool callers switch to constructing this subclass. +/// Pool-aware RegisterDMAsync override — +/// cppcache TcrPoolEndPoint::registerDM diverges from the +/// non-pool base around subscription / redundancy. +/// Per-endpoint handleNotificationStats — +/// fires ReceivedBytes + +/// MessagesBeingReceived (#21, Phase 2+ subscription +/// channel). +/// +/// +/// Until callers ( +/// in pool mode) switch to constructing , +/// pool-mode endpoints stay base instances +/// and this subclass is unused. The unsealed base + empty subclass +/// is the structural placeholder for the migration. +/// +/// +internal sealed class TcrPoolEndPoint( + IServiceProvider serviceProvider, + ILogger logger, + DnsEndPoint endpoint, + TcrConnectionManager tcrConnectionManager) + : TcrEndpoint(serviceProvider, logger, endpoint, tcrConnectionManager) +{ + // cppcache m_dm: ThinClientPoolDM* — set in ctor; routed through + // every pool-mode override. Migration pending; see class xmldoc. +} + diff --git a/src/Geode.Client/Internal/ThinClientBaseDM.cs b/src/Geode.Client/Internal/ThinClientBaseDM.cs new file mode 100644 index 0000000..cf163ac --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientBaseDM.cs @@ -0,0 +1,288 @@ +using System.Threading.Channels; +using Geode.Client.Protocol; +using Geode.Client.Internal; + +namespace Geode.Client.Internal; + +/// +/// Abstract base of the distribution-manager hierarchy. Mirrors +/// cppcache ThinClientBaseDM +/// (cppcache/src/ThinClientBaseDM.hpp/.cpp) — the common +/// contract every DM (simple +/// or pool ) must satisfy. +/// +/// +/// +/// Owns: lifecycle flags, async chunk-context queue, security / +/// multi-user hooks (default empty), interest-registration template. +/// Pure abstract: and +/// — request dispatch is +/// each DM's own job. +/// +/// +/// Phase 1.5 we only build the pool variant; the base + simple DM +/// shells exist so their inheritance / registration paths line up +/// 1:1 with cppcache during implementation. +/// +/// +internal abstract class ThinClientBaseDM( + IServiceProvider serviceProvider, + GeodeCache cache) : IAsyncDisposable +{ + + //protected readonly TcrConnectionManager ConnManager; // m_connManager + //protected readonly object? Region; // m_region (ThinClientRegion*) + protected bool InitDone; // m_initDone + + public virtual void DecConnectedEndpoints() { } + + //public virtual Task RegisterInterestForRegionAsync( + // TcrEndpoint endpoint, + // object? region = null, + // CancellationToken ct = default) + // => Task.FromResult(/*GF_NOERR* / 0); + + ///// + ///// Push a chunked-response context onto for + ///// the chunk-processor to consume. Mirrors cppcache + ///// queueChunk. + ///// + //public void QueueChunk(object chunk) + //{ + // ArgumentNullException.ThrowIfNull(chunk); + // Chunks.Writer.TryWrite(chunk); + //} + + //// ── Static error classifiers (cppcache inline static) ────── + + ///// Mirrors cppcache isFatalError(GfErrType). + //public static bool IsFatalError(int err) + //{ + // // TODO: port the cppcache GfErrType enum table once GfErrType lands. + // return false; + //} + + ///// Mirrors cppcache isFatalClientError(GfErrType). + //public static bool IsFatalClientError(int err) + //{ + // // TODO: same as IsFatalError. + // return false; + //} + + //public async ValueTask DisposeAsync() + //{ + // if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + // await DestroyAsync(keepAlive: false).ConfigureAwait(false); + // ChunkCts.Cancel(); + // ChunkCts.Dispose(); + //} + public ValueTask DisposeAsync() + { + // todo + return ValueTask.CompletedTask; + + } + + //// ── Template methods (concrete; delegate to derived) ─────── + + ///// + ///// Interest registration helper. Mirrors cppcache + ///// sendSyncRequestRegisterInterest — when + ///// is null delegate to + ///// ; otherwise delegate to + ///// . A disconnected + ///// endpoint surfaces as a rather than + ///// cppcache's GF_NOTCON error code. + ///// + //public virtual Task SendSyncRequestRegisterInterestAsync( + // TcrMessage request, + // bool attemptFailover = true, + // TcrEndpoint? endpoint = null, + // CancellationToken ct = default) + //{ + // if (endpoint is null) + // { + // return SendSyncRequestAsync(request, attemptFailover, false, ct); + // } + // if (!endpoint.IsConnected) + // { + // throw new GeodeException( + // $"Endpoint {endpoint.Name} is not connected (cppcache GF_NOTCON)."); + // } + // return SendRequestToEndpointAsync(request, endpoint, ct); + //} + + //// ── Empty virtual hooks (override in derived if needed) ──── + + //public virtual Task FailoverAsync(CancellationToken ct = default) => Task.CompletedTask; + //public virtual void AcquireFailoverLock() { } + //public virtual void ReleaseFailoverLock() { } + //public virtual void AcquireRedundancyLock() { } + //public virtual void ReleaseRedundancyLock() { } + //public virtual void TriggerRedundancyThread() { } + + public virtual bool IsSecurityOn => false; // TODO: ConnManager.HasAuthInitialize when wired + public virtual bool IsMultiUserMode => false; + + ///// + ///// True when is an + ///// AuthenticationRequiredException reply text from the server, + ///// signalling the outer dispatcher to unauth + retry. + ///// + ///// + ///// Mirrors ThinClientBaseDM::isAuthRequireException + ///// (cppcache/src/ThinClientBaseDM.cpp:374): substring-match for + ///// "org.apache.geode.security.AuthenticationRequiredException". + ///// Phase 3 — only the security / multi-user dispatch path needs it, so + ///// it stays a NIE stub until TcrMessage.GetException() + the + ///// auth-retry loop land. + ///// + //protected virtual bool IsAuthRequireException(string exceptionMsg) => + // throw new NotImplementedException( + // "Phase 3 — ThinClientBaseDM.IsAuthRequireException (auth-retry detection)"); + + //public virtual void BeforeSendingRequest(object request, object connection) { } + //public virtual void AfterSendingRequest(object request, object reply, object connection) { } + + //public virtual TcrEndpoint? ActiveEndpoint => null; + //public virtual int NumberOfEndpoints => 0; + + //public virtual bool IsEndpointAttached(TcrEndpoint endpoint) => false; + public virtual void IncConnectedEndpoints() { } + //protected bool ClientNotification; // m_clientNotification + + ///// + ///// Async chunked-response queue. Mirrors cppcache + ///// m_chunks + m_chunkProcessor Task. + ///// + //protected readonly Channel Chunks = + // Channel.CreateUnbounded(new UnboundedChannelOptions + // { + // SingleReader = true, + // SingleWriter = false, + // }); + //protected Task? ChunkProcessor; + //protected readonly CancellationTokenSource ChunkCts = new(); + + //private int _disposed; + + //protected ThinClientBaseDM(TcrConnectionManager connManager, object? region) + //{ + // ArgumentNullException.ThrowIfNull(connManager); + // ConnManager = connManager; + // Region = region; + //} + + //// ── Lifecycle ────────────────────────────────────────────── + + /// + /// One-time init. Mirrors cppcache ThinClientBaseDM::init(): + /// optionally start the chunk-processor task, set + /// . Derived classes call base.InitAsync + /// at the end of their own init. + /// + public virtual Task InitAsync(CancellationToken ct = default) + { + // TODO: if options.EnableChunkHandlerThread → StartChunkProcessor. + InitDone = true; + return Task.CompletedTask; + } + + ///// + ///// Mirrors cppcache destroy(keepalive): stop chunk + ///// processor, mark not-initialised. + ///// + //public virtual Task DestroyAsync(bool keepAlive = false, CancellationToken ct = default) + //{ + // if (!InitDone) return Task.CompletedTask; + // // TODO: stopChunkProcessor; await ChunkProcessor. + // InitDone = false; + // return Task.CompletedTask; + //} + + //// ── Pure abstract: each DM implements its own dispatch ───── + + /// + /// Send a request and await the server's reply. Mirrors cppcache + /// pure-virtual sendSyncRequest(request, reply, ...); cppcache + /// mutates the caller-supplied reply in place and returns + /// GfErrType, but .NET transport errors surface as exceptions + /// ( / ) + /// so we return the reply directly. Callers inspect + /// for protocol-level errors + /// () themselves. + /// + public abstract Task SendSyncRequestAsync( + TcrMessage request, + bool attemptFailover = true, + bool isBackgroundThread = false, + CancellationToken ct = default); + + /// + /// Chunked-reply overload — send a request whose reply + /// arrives across multiple frames (RemoveAll, PutAll, GetAll70, + /// Query, registerInterest, executeFunction…). The dispatcher + /// registers against the request's + /// transaction id; is + /// invoked once per arriving chunk and the returned + /// resolves only after the final chunk + /// (isLastChunk=true) is delivered. + /// + /// + /// + /// Mirrors cppcache sendSyncRequest(request, reply, attemptFailover, + /// isBGThread) when reply.m_chunkedResult is set via + /// TcrMessageReply::setChunkedResultHandler ahead of dispatch + /// (cppcache/src/ThinClientRegion.cpp:1830-1832). The + /// single-message overload above (no chunkedResult) maps to + /// cppcache's reply.m_chunkedResult == nullptr branch. + /// + /// + /// Phase 1.3.b status: declaration only. Concrete dispatch + /// () throws + /// until the + /// reader-loop refactor lands and + /// _pendingReplies can route chunks to the registered + /// result. + /// + /// + public abstract Task SendSyncRequestAsync( + TcrMessage request, + TcrChunkedResult chunkedResult, + bool attemptFailover = true, + bool isBackgroundThread = false, + CancellationToken ct = default); + + /// + /// Send to a specific endpoint, bypassing DM-level routing / + /// load-balancing / failover. Mirrors cppcache pure-virtual + /// sendRequestToEP(request, reply, endpoint); same + /// return-vs-mutate convention as + /// . + /// + public abstract Task SendRequestToEndpointAsync( + TcrMessage request, + TcrEndpoint endpoint, + CancellationToken ct = default); + + /// + /// Chunked-reply variant. Same endpoint-pinned dispatch as + /// + /// but the wire-I/O leg uses + /// + /// so each arriving chunk flows into + /// . + /// + public abstract Task SendRequestToEndpointAsync( + TcrMessage request, + TcrChunkedResult chunkedResult, + TcrEndpoint endpoint, + CancellationToken ct = default); + + /// + /// Shortcut to ; saves the + /// double-hop poolDM.PoolManager.Cache at call sites. + /// + public GeodeCache Cache => cache; + +} diff --git a/src/Geode.Client/Internal/ThinClientLocatorHelper.cs b/src/Geode.Client/Internal/ThinClientLocatorHelper.cs new file mode 100644 index 0000000..240ac54 --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientLocatorHelper.cs @@ -0,0 +1,376 @@ +using System.Buffers; +using System.Runtime.InteropServices; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Pool-scoped helper that talks to locators on behalf of a +/// . Mirrors cppcache +/// ThinClientLocatorHelper +/// (cppcache/src/ThinClientLocatorHelper.hpp/.cpp): owns the +/// pool's current view of the locator list, sends locator-protocol +/// requests, and refreshes the list periodically. +/// +/// +/// cppcache full surface (4 public methods): updateLocators, +/// getEndpointForNewFwdConn, getEndpointForNewCallBackConn, +/// getAllServers. We currently implement the first two; the +/// subscription-channel and metadata variants are Phase 2+ / Phase 4+. +/// SNI proxy fields (cppcache m_sniProxyHost / +/// m_sniProxyPort) are deferred to Phase 3 TLS work. +/// +internal sealed class ThinClientLocatorHelper( + IServiceProvider serviceProvider, + ILogger logger, + List initialLocators, + int connectionRetries) +{ + /// + /// cppcache ThinClientLocatorHelper.cpp:117 ??magic int prefix to every locator request. + /// + private const int GossipVersion = 1002; + + /// + /// cppcache TcrConnection.hpp:44: first byte the locator sends when it requires SSL but the client did not enable TLS. + /// + private const byte ReplySslEnabled = 21; + + + private readonly List _locators = [.. initialLocators]; + private readonly Lock _swapLock = new(); + + /// + /// Current size of the locator list. Mirrors cppcache + /// getCurLocatorsNum() (ThinClientLocatorHelper.hpp:68), + /// but read under _swapLock so 's + /// clear+append swap can't be observed mid-mutation. + /// + public int LocatorCount + { + get { lock (_swapLock) return _locators.Count; } + } + // Caller (ThinClientPoolDM) guarantees >= 0 via CachePoolOptions + // validator + default 3. cppcache's getConnRetries() sentinel-resolves + // <=0 to 3; we surface the resolved default at the Options layer so 0 + // can mean "no retries" end-to-end. + private readonly int _connectionRetries = connectionRetries; + + + /// + /// Refresh the locator list from the cluster. Mirrors cppcache + /// ThinClientLocatorHelper::updateLocators + /// (ThinClientLocatorHelper.cpp:281-313): walk a shuffled + /// snapshot of locators, ask the first one that responds for the + /// authoritative set, merge with the client's known set (preserving + /// client-known entries the server didn't echo back), and swap + /// atomically. Throws only when no + /// locator could be reached at all. + /// + public async Task UpdateLocatorsAsync(string serverGroup, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(serverGroup); + + var snapshot = SnapshotShuffledLocators(); + var requestBytes = BuildLocatorListRequestFrame(serverGroup); + + foreach (var loc in snapshot) + { + logger.LogTrace( + "ThinClientLocatorHelper: querying locator [{Host}:{Port}] for serverGroup='{Group}'", + loc.Host, loc.Port, serverGroup); + + var response = await TrySendAsync( + loc, requestBytes, DSFid.LocatorListResponse, + LocatorListResponse.ReadFrom, ct).ConfigureAwait(false); + if (response is null) continue; + + var merged = Merge(response.Locators, snapshot); + SwapLocators(merged); + logger.LogDebug( + "ThinClientLocatorHelper: refreshed locator list via [{Host}:{Port}]; old size {Old}, new size {New}, isBalanced={Balanced}", + loc.Host, loc.Port, snapshot.Count, merged.Count, response.IsBalanced); + return; + } + + throw new GeodeException( + $"updateLocators(serverGroup='{serverGroup}'): no locator reachable " + + $"from {snapshot.Count} configured."); + } + + /// + /// Ask the locator pool for one server to open a forward + /// (client ??server) connection on. Mirrors cppcache + /// ThinClientLocatorHelper::getEndpointForNewFwdConn + /// (ThinClientLocatorHelper.cpp:222-279). + /// + /// + /// Two failure modes the caller cares about: + /// + /// + /// All locators unreachable ??cppcache + /// NoAvailableLocatorsException; we surface as + /// . + /// + /// + /// Some locator answered but no server matched the group ?? /// cppcache NotConnectedException("No servers found"); we + /// surface as with the message. + /// + /// + /// ClientReplacementRequest (cppcache failover path when a + /// specific currentServer is known to be unhealthy) is + /// deferred; this entry point only sends ClientConnectionRequest. + /// + public async Task GetEndpointForNewFwdConnAsync( + string serverGroup, + IReadOnlyCollection excludeServers, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(serverGroup); + ArgumentNullException.ThrowIfNull(excludeServers); + + var snapshot = SnapshotShuffledLocators(); + if (snapshot.Count == 0) + { + throw new GeodeException("getEndpointForNewFwdConn: no locators configured / known."); + } + + var requestBytes = BuildClientConnectionRequestFrame(serverGroup, excludeServers); + + var locatorFound = false; + // cppcache iterates `attempt < maxAttempts` cycling locators mod size, + // so the same locator can be retried until the attempt budget runs out. + for (var attempt = 0; attempt < _connectionRetries; attempt++) + { + var loc = snapshot[attempt % snapshot.Count]; + logger.LogTrace( + "ThinClientLocatorHelper: asking locator [{Host}:{Port}] for server in group='{Group}'", + loc.Host, loc.Port, serverGroup); + + var response = await TrySendAsync( + loc, requestBytes, DSFid.ClientConnectionResponse, + ClientConnectionResponse.ReadFrom, ct).ConfigureAwait(false); + if (response is null) continue; + + if (!response.ServerFound) + { + // Locator was reachable but reported no eligible server ?? // remember that so we can distinguish "no locator reachable" + // from "locators say cluster is empty" at the end. + locatorFound = true; + logger.LogTrace( + "ThinClientLocatorHelper: locator [{Host}:{Port}] reports no server in group='{Group}'", + loc.Host, loc.Port, serverGroup); + continue; + } + + // Server found ??response.Server is non-null when ServerFound=true + // (enforced by ClientConnectionResponse.ReadFrom). + var server = response.Server!; + logger.LogDebug( + "ThinClientLocatorHelper: locator [{Host}:{Port}] returned server [{ServerHost}:{ServerPort}] for group='{Group}'", + loc.Host, loc.Port, server.Host, server.Port, serverGroup); + return server; + } + + // Out of attempts ??cppcache distinguishes the two failure modes. + if (locatorFound) + { + throw new GeodeException( + $"getEndpointForNewFwdConn(serverGroup='{serverGroup}'): " + + $"no server found across {_connectionRetries} attempts."); + } + throw new GeodeException( + $"getEndpointForNewFwdConn(serverGroup='{serverGroup}'): " + + $"no locator reachable across {_connectionRetries} attempts."); + } + + + /// + /// Lock + copy + shuffle. Mirrors cppcache getLocators() (ThinClientLocatorHelper.cpp:75-85). + /// + private List SnapshotShuffledLocators() + { + List snapshot; + lock (_swapLock) { snapshot = [.. _locators]; } + Random.Shared.Shuffle(CollectionsMarshal.AsSpan(snapshot)); + return snapshot; + } + + private static List Merge( + IReadOnlyList serverList, + IReadOnlyList clientList) + { + // cppcache ThinClientLocatorHelper.cpp:298-303 ??preserve + // client-known entries the server didn't echo back. + var merged = new List(serverList); + foreach (var oldLoc in clientList) + { + if (!merged.Contains(oldLoc)) + { + merged.Add(oldLoc); + } + } + return merged; + } + + private void SwapLocators(List merged) + { + // cppcache: boost::unique_lock + locators_.swap(new_locators). + // We mutate contents under the lock; readonly field can't have + // its ref replaced, semantics are the same. + lock (_swapLock) + { + _locators.Clear(); + _locators.AddRange(merged); + } + } + + + private byte[] BuildLocatorListRequestFrame(string serverGroup) + => BuildRequestFrame( + DSFid.LocatorListRequest, + writer => new LocatorListRequest(serverGroup).WriteTo(writer)); + + private byte[] BuildClientConnectionRequestFrame( + string serverGroup, IReadOnlyCollection excludeServers) + => BuildRequestFrame( + DSFid.ClientConnectionRequest, + writer => new ClientConnectionRequest(serverGroup, excludeServers).WriteTo(writer)); + + /// + /// Common outer wrapping for every locator request: gossip version, + /// Geode version ordinal, FixedIDByte envelope, then the body via + /// . Mirrors cppcache sendRequest + /// (ThinClientLocatorHelper.cpp:128-132). + /// + /// + /// Locator DSFids in all fit in , + /// so is the only envelope tag we + /// need here. If a request type ever lands with a DSFid outside + /// [-128, 127], switch to FixedIDShort/FixedIDInt + /// with the matching write width. + /// + private byte[] BuildRequestFrame(DSFid dsfid, Action writeBody) + { + using var writer = ActivatorUtilities.CreateInstance(serviceProvider); + + writer.WriteInt32(GossipVersion); + // Ordinal MUST be int16 ??Java TcpServer.processOneConnection reads + // `input.readShort()` at TcpServer.java:413; an int32 here leaves + // the trailing 2 bytes mis-aligning the DSCode/DSFid envelope and + // the server rejects with + // `UnsupportedSerializationVersionException: ordinal 0 not supported`. + // Matches Java TcpClient.java:312 (`writeShort(ordinalVersion)`). + writer.WriteInt16(ProtocolVersion.Current.Ordinal); + writer.WriteByte(DSCode.FixedIDByte); + writer.WriteSByte((sbyte)dsfid); + writeBody(writer); + + return writer.WrittenSpan.ToArray(); + } + + + /// + /// Send to , + /// verify the envelope, and decode the body via + /// . Returns + /// on transport-level failures (caller advances to the next locator). + /// Mirrors cppcache sendRequest + /// (ThinClientLocatorHelper.cpp:118-169). + /// + private async Task TrySendAsync( + ServerLocation loc, + byte[] requestBytes, + DSFid expectedDsfid, + Func bodyDecoder, + CancellationToken ct) where T : class + { + try + { + await using var conn = ActivatorUtilities.CreateInstance(serviceProvider); + await conn.ConnectAsync(loc.Host, loc.Port, ct).ConfigureAwait(false); + await conn.SendAsync(requestBytes, ct).ConfigureAwait(false); + + // Response length is not pre-framed; grow the buffer + // chunk-by-chunk and retry parsing after each read. + // EndOfStreamException from BigEndianBinaryReader means + // "need more bytes". + var buffer = new byte[4096]; + var totalRead = 0; + while (true) + { + try + { + var reader = new DataInput(buffer.AsMemory(0, totalRead)); + ReadEnvelope(reader, expectedDsfid); + return bodyDecoder(reader); + } + catch (EndOfStreamException) + { + // Need more bytes ??fall through to the read below. + } + + if (totalRead == buffer.Length) + { + Array.Resize(ref buffer, buffer.Length * 2); + } + + var n = await conn.ReadAsync(buffer.AsMemory(totalRead), ct).ConfigureAwait(false); + if (n == 0) + { + logger.LogDebug( + "Locator [{Host}:{Port}] closed connection before complete response", + loc.Host, loc.Port); + return null; + } + totalRead += n; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (NotSupportedException) + { + // SSL reject ??propagate (no point trying other locators in + // the same cluster, they almost certainly require SSL too). + throw; + } + catch (Exception ex) + { + logger.LogDebug(ex, + "Exception while querying locator [{Host}:{Port}]", + loc.Host, loc.Port); + return null; + } + } + + /// + /// Consume the outer envelope: optional SSL-reject byte ??DSCode + /// FixedIDByte ??DSFid sbyte. Throws on shape mismatch so + /// the caller's catch-all reports it as a malformed locator + /// response. + /// + private static void ReadEnvelope(DataInput reader, DSFid expectedDsfid) + { + // cppcache: di.read() ??if REPLY_SSL_ENABLED, throw; else rewind. + // The byte serves dual purpose; we don't rewind, we just consume. + var first = reader.ReadByte(); + if (first == ReplySslEnabled) + { + throw new NotSupportedException("Locator requires SSL; client TLS is not yet supported (Phase 3)."); + } + if (first != DSCode.FixedIDByte) + { + throw new GeodeException($"Locator response: unexpected envelope DSCode {first} (expected FixedIDByte={DSCode.FixedIDByte})."); + } + + var dsfid = (DSFid)reader.ReadSByte(); + if (dsfid != expectedDsfid) + { + throw new GeodeException($"Locator response: unexpected DSFid {dsfid} (expected {expectedDsfid})."); + } + } +} diff --git a/src/Geode.Client/Internal/ThinClientPoolDM.cs b/src/Geode.Client/Internal/ThinClientPoolDM.cs new file mode 100644 index 0000000..12df480 --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientPoolDM.cs @@ -0,0 +1,1972 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Xml.Linq; +using Geode.Client.Protocol; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Geode.Client.Internal; + +internal class ThinClientPoolDM( + IServiceProvider serviceProvider, + ILogger logger, + PoolManager poolManager, + string name, + PoolAttributes attributes) + : ThinClientBaseDM(serviceProvider, poolManager.Cache), IPool +{ + /// + /// 0 / 1 destroy guard, gated by . + /// + private int _isDestroyed; + /// + /// 0 = not run, 1 = ran. Mirrors cppcache + /// pool DM's one-shot init guard; gated by + /// . + /// + private int _initGuard; + /// + /// Pool stats sink (Meter + ActivitySource). Mirrors cppcache + /// m_stats / PoolStats. + /// + private readonly PoolStatistics _stats = ActivatorUtilities.CreateInstance(serviceProvider, name); + /// + /// Current pool conn count (cppcache m_poolSize). + /// Bumped in after handshake, + /// decremented on every close site. Surfaced via . + /// + private int _poolSize = 0; + /// + /// Pool's view onto TCCM-owned instances — + /// tracks which endpoints this pool holds a ref on so destroy knows + /// what to release. + /// + private readonly ConcurrentDictionary _endpoints = new(); + /// + /// Serialises endpoint selection in + /// (round-robin cursor + locator pick). Mirrors cppcache + /// m_endpointSelectionLock. + /// + private readonly Lock _endpointSelectionLock = new(); + /// + /// Current count of endpoints whose + /// is true. Mirrors cppcache connected_endpoints_ + /// (ThinClientPoolDM.cpp:2055-2068). Bumped / decremented by + /// / , + /// which fire from 's broadcast + /// on real 0↔1 transitions. Surfaced via the + /// ConnectedServers ObservableGauge. + /// + private int _connectedEndpoints = 0; + /// + /// Threads currently inside the pool-wide wait + /// in / + /// . Mirrors cppcache + /// connectionWaitsInProgress (PoolStatistics.cpp:74-76, + /// bumped in getConnectionFromQueue at :1819). Surfaced via + /// the ConnectionWaitsInProgress ObservableGauge. cppcache + /// instruments only the pool-wide cap; per-EP cap waits + /// () are our addition and + /// not counted here. + /// + private int _connectionWaitsInProgress = 0; + /// + /// In-flight pool ops — incremented at + /// entry, decremented in finally. Mirrors cppcache + /// m_clientOps tracked via setCurClientOps(++m_clientOps) / + /// setCurClientOps(--m_clientOps) around sendSyncRequest + /// (ThinClientPoolDM.cpp:1272, 1519, 1538). Surfaced via the + /// ClientOpsInProgress ObservableGauge. + /// + private int _clientOpsInProgress = 0; + /// + /// cppcache m_isMultiUserMode — from . + /// + private bool _isMultiUserMode; + /// + /// Cancellation source for every background loop the pool spawns; + /// cancels it to signal graceful shutdown. + /// + private readonly CancellationTokenSource _backgroundCts = new(); + /// + /// cppcache m_isSecurityOn — true when the cache has any security-* property set (proxy until Phase 3 auth callback lands). + /// + private bool _isSecurityOn; + /// + /// keepAlive intent stashed in for + /// each conn's . + /// + private bool _keepAlive; + private readonly LinkedList _opConnections = new(); + + /// + /// Mutex for ; mirror of cppcache mutex_. + /// + private readonly Lock _opConnLock = new(); + /// + /// slot reservation: + /// WaitAsync() + /// on open, Release on close. null when unbounded. + /// + // MaxConnections == -1 (default) / 0 → unbounded; positive value is the cap. + private readonly SemaphoreSlim? _capSlots = attributes.MaxConnections > 0 + ? new SemaphoreSlim(attributes.MaxConnections, attributes.MaxConnections) + : null; + + /// + /// Static-server round-robin cursor for + /// ; read + post-increment-with-wrap + /// under . Mirrors cppcache + /// m_server. Init-time random start so concurrent client + /// startups don't all hit Servers[0] first. + /// + private int _server = attributes.Servers.Count > 0 + ? Random.Shared.Next(attributes.Servers.Count) + : 0; + + /// + /// Pool-scoped query service. Lazy-built on first + /// access (primary-ctor field-init can't + /// reference this). Mirrors cppcache + /// m_remoteQueryServicePtr. + /// + private RemoteQueryService? _queryService; + public IQueryService QueryService => + LazyInitializer.EnsureInitialized( + ref _queryService, + () => ActivatorUtilities.CreateInstance( + serviceProvider, this, Cache.SerializationRegistry)); + + /// + /// Whether to clear cached PDX type IDs when the pool fully disconnects. + /// Mirrors cppcache clear_pdx_registry_; sourced from + /// . Reader + /// (decConnectedEndpointsclearPdxTypeRegistry) is Phase 2+. + /// + private bool _clearPdxRegistry; + + /// + /// Open sockets + handshake with the configured locators/servers. Mirrors + /// cppcache ThinClientPoolDM::init called from the tail of + /// PoolFactory::create. No-op until the wire layer lands. + /// + public async override Task InitAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDestroyed) != 0, typeof(ThinClientPoolDM)); + if (Interlocked.Exchange(ref _initGuard, 1) != 0) return; + + _stats.SetPoolConnectionsReader(() => Volatile.Read(ref _poolSize)); + _stats.SetServersReader(() => _endpoints.Count); + _stats.SetConnectedServersReader(() => Volatile.Read(ref _connectedEndpoints)); + _stats.SetConnectionWaitsInProgressReader(() => Volatile.Read(ref _connectionWaitsInProgress)); + _stats.SetClientOpsInProgressReader(() => Volatile.Read(ref _clientOpsInProgress)); + _stats.SetLocatorsReader(() => _locatorHelper?.LocatorCount ?? 0); + + _isMultiUserMode = attributes.MultiuserSecureMode; + if (_isMultiUserMode) + { + logger.LogInformation("Multiuser authentication is enabled for pool {PoolName}", name); + } + _isSecurityOn = poolManager.Cache.CacheProperties.SecurityProperties.Count > 0; + logger.LogDebug("ThinClientPoolDM.InitAsync: security on/off = {IsSecurityOn}", _isSecurityOn); + + //_stickyManager = ActivatorUtilities.CreateInstance(serviceProvider, this); + //_clearPdxRegistry = options.Pdx.ClearTypeIdsOnDisconnect; + + // ── TCCM init — hoisted to Cache.InitializeCoreAsync. + + await StartBackgroundThreads(ct).ConfigureAwait(false); + + // ── Lazy conn opening — first conn opens via ConnManageLoop (RestoreMinConnections) or SendRequestToEndpointAsync. + } + + /// + /// Launch the pool's background machinery. Mirrors cppcache + /// ThinClientPoolDM::startBackgroundThreads() + /// (ThinClientPoolDM.cpp:264-371). + /// + private async Task StartBackgroundThreads(CancellationToken ct) + { + SchedulePingLoop(); + + ScheduleUpdateLocatorLoop(); + + _connManageLoop = ConnManageLoopAsync(_backgroundCts.Token); + + await base.InitAsync(ct).ConfigureAwait(false); + + if (attributes.PrSingleHopEnabled) + { + //_clientMetadataService = ActivatorUtilities.CreateInstance(serviceProvider, this); + //await _clientMetadataService.StartAsync(ct).ConfigureAwait(false); + } + } + + public async Task DestroyAsync(bool keepAlive = false, CancellationToken ct = default) + { + // Single override satisfies both ThinClientBaseDM.DestroyAsync + // (virtual) and IPool.DestroyAsync (interface). + // + // Mirror cppcache ThinClientPoolDM::destroy() order + // (ThinClientPoolDM.cpp:784-848). Inline TODOs flag steps not + // yet implemented; they sit at their cppcache-equivalent position. + // 0. checkRegions (cppcache L787) — TODO + // 1. mark destroyed (idempotent). Note: we set _isDestroyed + // at the top for guard safety; cppcache sets it at end (L842). + // 1b. close RemoteQueryService (cppcache L791-794) + // — PoolStatsSampler (L796-800): not ported (Meter-based). + // 2. cancel background CTS — every loop's Task.Delay / + // WaitAsync throws OperationCanceledException + // (cppcache L802-810 stopNoblock). + // 3. await each background Task so they fully unwind + // (cppcache L815 stopPingThread, L818 stopUpdateLocator). + // 3b. stop ClientMetadataService (cppcache L820-823). + // 4. dispose timers + sync primitives (C#-only; cppcache RAII). + // 5a. drain _opConnections, CloseConnection(18) per conn + // (cppcache L829 ConnectionQueue close). + // 5b. release TCCM endpoint refs — TODO Phase 1.5 + // (ConnManager.RemoveRefToTcrEndpointAsync). + // 5c. unregister PoolConnections gauge reader. Full + // _stats.Close() (cppcache L835 getStats().close()) — TODO + // forceSample (L836) — not needed for Meter (pull-based). + // 5d. PoolManager.RemovePool(name) (cppcache L838) — TODO + // 6. base.DestroyAsync — mirror cppcache stopChunkProcessor + // (L840). Caller's ct flows through to base and to per-conn + // CloseAsync; background-loop cancellation is separate via + // _backgroundCts. + // 6b. closeAllStickyConnections post-close (cppcache L841). + // 7. warn if pool size != 0 (cppcache L846-848) — TODO + + // 0. TODO: checkRegions — cppcache L787 verifies region consistency + // before tearing down (e.g. no pending PR ops on dead buckets). + + // 1. Idempotent destroy guard. + if (Interlocked.Exchange(ref _isDestroyed, 1) != 0) + { + return; + } + + // Stash the caller's keepAlive intent for Step 5a's CloseAsync calls. + // cppcache: m_keepAlive = keepAlive (ThinClientPoolDM.cpp:789). + _keepAlive = keepAlive; + + //// 1b. Close pool-owned RemoteQueryService if it was ever + //// accessed. Mirrors cppcache CacheImpl::close() → + //// m_remoteQueryServicePtr->close(); we trigger it from pool + //// destroy because the RQS lives on the pool, not the cache. + //// Read the field directly (not the property) — we don't want + //// to lazy-create an RQS just to immediately close it. + //_queryService?.Close(); + + //// 2. Signal every background loop to stop. + _backgroundCts.Cancel(); + + // 3. Await each loop's graceful exit. OperationCanceledException + // is expected here — that IS the graceful exit signal. + if (_connManageLoop is not null) + { + try { await _connManageLoop.ConfigureAwait(false); } + catch (OperationCanceledException) + { + } + } + if (_pingLoop is not null) + { + try { await _pingLoop.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + if (_updateLocatorLoop is not null) + { + try { await _updateLocatorLoop.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } + + //// 3b. Stop the client metadata service. cppcache + //// ThinClientPoolDM::destroy (L820-823): after loops, before + //// ConnectionQueue close. + //if (_clientMetadataService is not null) + //{ + // await _clientMetadataService.StopAsync(ct).ConfigureAwait(false); + //} + + // 4. Dispose timers + sync primitives owned by this pool. + _pingTimer?.Dispose(); + _updateLocatorTimer?.Dispose(); + _pingSignal.Dispose(); + _connManageSignal.Dispose(); + _updateLocatorSignal.Dispose(); + _backgroundCts.Dispose(); + _capSlots?.Dispose(); + + // 5a. Drain _opConnections — every idle conn gets a polite + // CloseConnection(18) before its socket goes away. Mirrors + // cppcache ConnectionQueue::close (ConnectionQueue.hpp:87) + // invoked from ThinClientPoolDM::destroy (L829). + // Snapshot-and-clear under lock so CloseAsync's await isn't + // held under the lock (close I/O may be slow). + List drained; + lock (_opConnLock) + { + drained = [.. _opConnections]; + _opConnections.Clear(); + } + foreach (var conn in drained) + { + // CloseAsync sends MessageType.CloseConnection(18) then + // disposes the socket. Currently NIE — until the leaf lands, + // any drained conn here will throw and bubble out of + // DestroyAsync. Top-down: call site is in place, leaf next. + await conn.CloseAsync(_keepAlive, ct).ConfigureAwait(false); + } + + // 5b. TODO Phase 1.5: release pool's TCCM refs to endpoints in + // _endpoints (ConnManager.RemoveRefToTcrEndpointAsync). Phase + // 1.1: rely on cache-scope dispose to cascade. + _endpoints.Clear(); + + // 5c. Unregister gauge readers so the static registries in + // PoolStatistics don't leak this pool's entries. + // TODO: full _stats.Close() to match cppcache getStats().close() + // (L835) — drop static-registry entries for every instrument, + // not just these gauges. forceSample (L836) is not needed for + // Meter (listeners pull on their own cadence). + _stats.ClearPoolConnectionsReader(); + _stats.ClearLocatorsReader(); + _stats.ClearServersReader(); + _stats.ClearConnectedServersReader(); + _stats.ClearConnectionWaitsInProgressReader(); + _stats.ClearClientOpsInProgressReader(); + + //// 5d. TODO: PoolManager.RemovePool(name) — cppcache L838 + //// `cacheImpl->getPoolManager().removePool(m_poolName)` + //// unregisters the pool from cache's registry. Needs a + //// RemovePool API on our PoolManager first (verify whether + //// one exists; if not, add it). + + //// 6. Mirror cppcache stopChunkProcessor (ThinClientPoolDM.cpp:840) — + //// base.DestroyAsync flips InitDone off and will stop the + //// chunk-processor task when its TODO lands. + //await base.DestroyAsync(keepAlive, ct).ConfigureAwait(false); + + //// 6b. closeAllStickyConnections — cppcache L841. + //if (_stickyManager is not null) + //{ + // await _stickyManager.CloseAllStickyConnectionsAsync(ct).ConfigureAwait(false); + //} + + // 7. TODO: warn if pool size != 0 — cppcache L846-848 logs FINE + // when m_poolSize.load() != 0 after destroy (diagnostic for + // leaked conns). One LogWarning when _poolSize > 0. + } + + public PoolManager PoolManager => poolManager; + + /// Pool name as registered via ; mirrors cppcache Pool::getName(). + internal string Name => name; + + public override Task SendRequestToEndpointAsync( + TcrMessage request, + TcrEndpoint endpoint, + CancellationToken ct = default) + => SendRequestToEndpointCoreAsync(request, chunkedResult: null, endpoint, ct); + + public override Task SendRequestToEndpointAsync( + TcrMessage request, + TcrChunkedResult chunkedResult, + TcrEndpoint endpoint, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(chunkedResult); + return SendRequestToEndpointCoreAsync(request, chunkedResult, endpoint, ct); + } + + private async Task SendRequestToEndpointCoreAsync( + TcrMessage request, + TcrChunkedResult? chunkedResult, + TcrEndpoint endpoint, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDestroyed) != 0, this); + + logger.LogDebug("ThinClientPoolDM::sendRequestToEP{Variant} type={MessageType} endpoint={Endpoint}", + chunkedResult is null ? "" : " (chunked)", + request.MessageType, + endpoint.Name); + + var conn = await GetFromEPAsync(endpoint, ct).ConfigureAwait(false); + var putConnInPool = true; + conn ??= await CreatePoolConnectionToAEndPointAsync(endpoint, ct).ConfigureAwait(false); + + if (conn is null) + { + endpoint.SetConnected(false); + throw new GeodeException($"ThinClientPoolDM: could not obtain a connection to {endpoint.Name}."); + } + + //// Phase 3 — auth / multi-user creds. cppcache ThinClientPoolDM.cpp:1912. + //if ((IsSecurityOn || IsMultiUserMode) && TcrMessage.IsUserInitiativeOps(request)) + //{ + // throw new NotImplementedException( + // "Phase 3 — SendUserCredentialsAsync (multi-user auth path)"); + //} + + try + { + var reply = chunkedResult is null + ? await conn.SendRequestAsync(request, ct).ConfigureAwait(false) + : await conn.SendRequestAsync(request, chunkedResult, ct).ConfigureAwait(false); + + // // Phase 3 — AuthenticationRequiredException retry. cppcache ThinClientPoolDM.cpp:1975. + // if (IsSecurityOn + // && reply.MessageType == MessageType.Exception + // && IsAuthRequireException(reply.GetException())) + // { + // // Phase 3 step list — mirror ThinClientPoolDM.cpp:1971-1992. + // // Step A — wrap Steps 1-3 in a retry frame: + // // `var retriesLeft = 2;` declared once before the + // // frame, body re-runnable while `retriesLeft >= 0`. + // // Step B — clear cached auth state on the failing endpoint: + // // single-user → endpoint.SetAuthenticated(false) + // // multi-user → userAttrs.UnauthenticateEP(ep) + // // (cppcache 1976-1980). + // // Step C — `retriesLeft--`; on `< 0` rethrow as + // // GeodeAuthenticationException (we don't mirror + // // cppcache's reset-to-NOERR + continue — C# + // // exceptions replace the GfErrType loop). + // // Step D — return conn to the pool (or dispose), same as + // // the Step 4 happy path; next iteration re-borrows. + // // Step E — loop to top of the retry frame; the + // // IsUserInitiativeOps + IsSecurityOn guard above + // // will then invoke SendUserCredentialsAsync before + // // re-sending. + // throw new NotImplementedException( + // "Phase 3 — auth-required reply: unauth + outer retry loop"); + // } + + + if (putConnInPool) + { + await PutInQueueAsync(conn, ct).ConfigureAwait(false); + } + else + { + await conn.DisposeAsync().ConfigureAwait(false); + } + + return reply; + } + catch (Exception ex) + { + // cppcache: setConnectionStatus(false) + removeEPConnections(1) + // + removeEPFromMetadataIfError. Phase 1.5 will refine via + // GfErrType classification (retry vs. mark-down). + endpoint.SetConnected(false); + if (putConnInPool) + { + Interlocked.Decrement(ref _poolSize); _capSlots?.Release(); + } + await conn.DisposeAsync().ConfigureAwait(false); + RemoveEPFromMetadataIfError(endpoint, ex); + throw; + } + } + + /// + /// Try borrow an idle already attached to + /// . Mirrors cppcache + /// ThinClientPoolDM::getFromEP. + /// + /// An idle conn for this endpoint, or null if none available. + private Task GetFromEPAsync(TcrEndpoint endpoint, CancellationToken ct) + { + // cppcache ThinClientPoolDM::getFromEP (ThinClientPoolDM.cpp:2156-2168): + // lock + iterate queue_ + return-and-erase first match by + // getEndpointObject() == theEP. LinkedList's in-place + // Remove(node) keeps FIFO for non-matches. + ct.ThrowIfCancellationRequested(); + lock (_opConnLock) + { + for (var node = _opConnections.First; node is not null; node = node.Next) + { + if (ReferenceEquals(node.Value.Endpoint, endpoint)) + { + _opConnections.Remove(node); + logger.LogDebug("ThinClientPoolDM::getFromEP matched conn for {Endpoint}", endpoint.Name); + return Task.FromResult(node.Value); + } + } + return Task.FromResult(null); + } + } + + /// + /// Record on the ReceivedBytes + /// Counter (catalogue #20). Called from + /// via the conn's + /// back-ref. Wrapper so + /// _stats stays encapsulated. + /// + internal void RecordReceivedBytes(long bytes) + { + _stats.ReceivedBytes(bytes); + } + + /// + /// Open a fresh on a specific + /// , bypassing + /// . Mirrors cppcache + /// ThinClientPoolDM::createPoolConnectionToAEndPoint + /// (ThinClientPoolDM.cpp:1663-1718). + /// + /// + /// + /// Caller must have already registered + /// via (or be iterating + /// directly, as + /// does). cppcache makes the same + /// assumption — this helper does not AddEP. + /// + /// + /// Returns null when the endpoint cannot currently be reached; + /// the caller () then falls + /// back to its own error path. Unlike + /// this does NOT enqueue — + /// the caller uses the conn immediately and returns it to the queue + /// after the send. + /// + /// + private async Task CreatePoolConnectionToAEndPointAsync( + TcrEndpoint endpoint, CancellationToken ct) + { + // Pool-wide MaxConnections cap (cppcache ThinClientPoolDM.cpp:1672-1687) — + // same AcquirePoolCapSlotAsync helper as CreatePoolConnectionAsync. + // cppcache signals "cap reached" via a maxConnLimit out-flag so the + // caller can fall back to a temporary non-pool conn; we throw + // AllConnectionsInUseException and let the caller catch it. + await AcquirePoolCapSlotAsync(ct).ConfigureAwait(false); + + var releasePoolSlot = true; + var releaseEndpointSlot = false; + try + { + // Per-endpoint cap (cppcache TcrEndpoint::m_maxConnections from + // connection-pool-size, TcrEndpoint.cpp:49-51). Sits beneath the + // pool-wide cap above. Released by TcrConnection.DisposeAsync via + // OwnsEndpointSlot once ownership transfers below. + if (!await endpoint.AcquireSlotAsync(attributes.FreeConnectionTimeout, ct).ConfigureAwait(false)) + { + throw new AllConnectionsInUseException( + $"Pool '{name}' endpoint '{endpoint.Name}': ConnectionPoolSize cap reached."); + } + releaseEndpointSlot = true; + + logger.LogDebug("ThinClientPoolDM::createPoolConnectionToAEndPoint: opening new connection to {Endpoint}", + endpoint.Name); + + TcrConnection conn; + try + { + conn = await endpoint + .CreateNewConnectionAsync( + pool: this, + isClientNotification: false, + isSecondary: false, + connectTimeout: poolManager.Cache.CacheProperties.ConnectTimeout, + ct: ct) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "ThinClientPoolDM::createPoolConnectionToAEndPoint: failed to connect to {Endpoint}", + endpoint.Name); + return null; + } + + // cppcache L1704-1712: mark endpoint healthy + grow counter + stats. + endpoint.SetConnected(true); + var newSize = Interlocked.Increment(ref _poolSize); + _stats.PoolConnect(); + if (newSize > attributes.MinConnections) + { + _stats.LoadConditioningConnect(); + } + + // Slot ownership transfers to the freshly-opened conn: pool-wide + // is still released manually by the DM at close sites; per-EP + // rides along on conn.DisposeAsync via OwnsEndpointSlot. + conn.OwnsEndpointSlot = true; + releasePoolSlot = false; + releaseEndpointSlot = false; + return conn; + } + finally + { + if (releasePoolSlot) _capSlots?.Release(); + if (releaseEndpointSlot) endpoint.ReleaseSlot(); + } + } + + /// + /// Reserve one slot, waiting up to + /// . No-op when + /// is null (unbounded pool). Throws + /// on timeout. The wait + /// is bracketed by + /// inc/dec — mirrors cppcache getConnectionFromQueue + /// (ThinClientPoolDM.cpp:1819, 1835) so the + /// ConnectionWaitsInProgress gauge reflects threads queued + /// on the pool-wide cap (inc/dec safe across cancellation and + /// throw via try/finally). + /// + private async Task AcquirePoolCapSlotAsync(CancellationToken ct) + { + if (_capSlots is null) return; + + // cppcache (:1819-1820) bumps the gauge (#12) AND the cumulative + // counter (#13) at the entry point, then records elapsed time + // (#14) after `getUntil` (:1822-1833). We collapse #13 + #14 into + // a single Histogram: .Count subsumes the wait-attempt counter, + // sum subsumes the cumulative time. Recording sits in `finally` + // so timeouts / cancellations still tick (matches the + // LocatorListRequestTime pattern). + Interlocked.Increment(ref _connectionWaitsInProgress); + var stopwatch = Stopwatch.StartNew(); + bool acquired; + try + { + acquired = await _capSlots.WaitAsync(attributes.FreeConnectionTimeout, ct).ConfigureAwait(false); + } + finally + { + Interlocked.Decrement(ref _connectionWaitsInProgress); + _stats.ConnectionWait(stopwatch.Elapsed); + } + if (!acquired) + { + throw new AllConnectionsInUseException( + $"Pool '{name}': MaxConnections={attributes.MaxConnections} reached."); + } + } + + /// + /// Return a borrowed to the pool queue. + /// Mirrors cppcache ThinClientPoolDM::put(conn, isTransaction) + /// (the false overload — sticky-tx routing is Phase 6). + /// + private async ValueTask PutInQueueAsync(TcrConnection conn, CancellationToken ct) + { + // Stamp last-access (under lock) so CleanStaleConnectionsAsync + // sees the same order as the enqueue. destroyed-guard test is + // deferred — see PROGRESS.md Phase 1.5 "PutInQueueAsync tests". + // Phase 6 (sticky-tx isTransaction overload, cppcache + // ThinClientPoolDM.cpp:2293-2300) is the other still-open branch. + ct.ThrowIfCancellationRequested(); + bool destroyed; + lock (_opConnLock) + { + destroyed = Volatile.Read(ref _isDestroyed) != 0; + if (!destroyed) + { + conn.Touch(); + _opConnections.AddLast(conn); + } + } + if (destroyed) + { + // Pool already destroyed (and its queue drained) — close this + // late-returning conn so its socket / stats don't leak. + // Mirrors cppcache `put` closed_ branch + // (ConnectionQueue.hpp:62-67): when the queue refuses the + // conn, close + delete instead of enqueue. + await conn.CloseAsync(_keepAlive, ct).ConfigureAwait(false); + } + } + + #region Locator + + private readonly SemaphoreSlim _updateLocatorSignal = new(0, int.MaxValue); + private Task? _updateLocatorLoop; + private PeriodicTimer? _updateLocatorTimer; + private ThinClientLocatorHelper? _locatorHelper; + + /// + /// Maybe launch . Mirrors + /// cppcache ThinClientPoolDM.cpp:292-302: only fires when + /// the pool actually has locators configured (no locators ⇒ + /// nothing to refresh). Default 5s baked into + /// + /// (cppcache PoolFactory.cpp:51); validator rejects + /// negatives. Interval == 0 disables (matches cppcache + /// L286-289). + /// + private void ScheduleUpdateLocatorLoop() + { + if (attributes.Locators.Count == 0) return; + + // Build the helper once we know locators are configured. cppcache + // ThinClientPoolDM ctor builds m_locHelper unconditionally; we + // gate on Locators so the field stays null when the pool runs + // static-server mode (Step E's SelectEndpointAsync locator branch + // never fires either, so no consumer of _locatorHelper exists). + // CacheHostPortOptions → ServerLocation conversion is the + // options-layer ↔ wire-layer boundary. + var initialLocators = attributes.Locators + .Select(l => new ServerLocation(l.Host, l.Port)) + .ToList(); + // cppcache ThinClientLocatorHelper::getConnRetries (L66-69): + // `retries <= 0 ? DEFAULT_CONNECTION_RETRIES(=3) : retries`. + // PoolAttributes.RetryAttempts default is -1 (the cppcache + // sentinel for "use default"); translate at the boundary so the + // helper's loop sees a positive bound. + const int DefaultConnectionRetries = 3; + var locatorRetries = attributes.RetryAttempts <= 0 + ? DefaultConnectionRetries + : attributes.RetryAttempts; + _locatorHelper = ActivatorUtilities.CreateInstance( + serviceProvider, initialLocators, locatorRetries); + + var updateInterval = attributes.UpdateLocatorListInterval; + if (updateInterval <= TimeSpan.Zero) + { + logger.LogDebug("ThinClientPoolDM::startBackgroundThreads: Not scheduling updateLocatorList as interval {Interval}", updateInterval); + return; + } + + logger.LogDebug("ThinClientPoolDM::startBackgroundThreads: Scheduling updateLocatorList task at {Interval}", updateInterval); + _updateLocatorTimer = new PeriodicTimer(updateInterval); + _updateLocatorLoop = UpdateLocatorLoopAsync(_backgroundCts.Token); + } + + /// + /// Periodic locator-list refresh loop. Mirrors cppcache + /// ThinClientPoolDM::updateLocatorList + /// (ThinClientPoolDM.cpp:2042-2053): each tick asks every + /// configured locator who's alive and updates the pool's known + /// locator list accordingly. cppcache's body is a + /// semaphore.acquire()-blocked task whose semaphore is + /// released by a separate FunctionExpiryTask; we collapse + /// that two-piece pattern into a single + /// loop (same shape as ). + /// + private async Task UpdateLocatorLoopAsync(CancellationToken ct) + { + // cppcache schedules with the same fixed 1 s initial delay as + // ping; first refresh fires ~1 s after pool init rather than + // after a full interval. + var initialDelay = TimeSpan.FromSeconds(1); + + logger.LogDebug("Starting updateLocatorList loop for pool {Pool}", name); + try + { + await Task.Delay(initialDelay, ct).ConfigureAwait(false); + + do + { + try + { + await UpdateLocatorsLocalAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + // One bad refresh must not kill the loop — next tick retries. + logger.LogWarning(ex, "updateLocatorList tick failed for pool {Pool}", name); + } + } + while (await _updateLocatorTimer!.WaitForNextTickAsync(ct).ConfigureAwait(false)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // graceful shutdown via _backgroundCts.Cancel(). + } + logger.LogDebug("Ending updateLocatorList loop for pool {Pool}", name); + } + + /// + /// One locator-list refresh. Mirrors cppcache + /// (m_locHelper)->updateLocators(getServerGroup()) + /// (ThinClientPoolDM.cpp:2048): asks the configured + /// locators for the current authoritative locator set so the pool + /// can drop dead locators and pick up newly-added ones. + /// + /// + /// Stub until ThinClientLocatorHelper lands; the loop's + /// scaffolding (timer, cancellation, error survival) is verified + /// first so its replacement only has to fill in the wire I/O. + /// + private async Task UpdateLocatorsLocalAsync(CancellationToken ct) + { + // _locatorHelper is non-null here: ScheduleUpdateLocatorLoop + // both builds it and launches this loop only when locators + // are configured (same gate, same call site). + using var activity = _stats.StartLocatorListRequest(); + var stopwatch = Stopwatch.StartNew(); + try + { + await _locatorHelper!.UpdateLocatorsAsync(attributes.ServerGroup, ct).ConfigureAwait(false); + } + finally + { + _stats.LocatorListRequest(stopwatch.Elapsed); + } + } + + /// + /// Query the pool's for one + /// server. Mirrors cppcache ThinClientPoolDM::selectEndpoint + /// locator branch (ThinClientPoolDM.cpp:580-604). + /// + /// + /// Phase 1.5 MVP: empty excludeServers and no + /// currentServer — neither failover-driven retry exclusion + /// nor server replacement is wired in yet. + /// + private async Task SelectEndpointFromLocatorAsync(HashSet excludeServers, CancellationToken ct) + { + logger.LogDebug("ThinClientPoolDM: Asking locator for server from group [{Group}]", attributes.ServerGroup); + + // Convert pool-layer DnsEndPoint set → wire-layer ServerLocation list + // at the helper boundary. ServerLocation is a value record so the + // List is cheap to materialise; ToList avoids exposing IEnumerable + // ordering quirks across the helper call. + var excludeWire = excludeServers.Select(e => new ServerLocation(e.Host, e.Port)).ToList(); + + // cppcache ThinClientPoolDM.cpp:587 — incLoctorRequests() before + // helper call. Maps to ClientConnectionRequest wire RPC. + using var activity = _stats.StartClientConnectionRequest(); + var stopwatch = Stopwatch.StartNew(); + ServerLocation server; + try + { + server = await _locatorHelper!.GetEndpointForNewFwdConnAsync(attributes.ServerGroup, excludeWire, ct) + .ConfigureAwait(false); + } + finally + { + _stats.ClientConnectionRequest(stopwatch.Elapsed); + } + + var endpoint = new DnsEndPoint(server.Host, server.Port); + + logger.LogDebug("ThinClientPoolDM: Locator returned endpoint [{Host}:{Port}]", endpoint.Host, endpoint.Port); + return endpoint; + } + + #endregion + + #region Ping + + private Task? _pingLoop; + private PeriodicTimer? _pingTimer; + private readonly SemaphoreSlim _pingSignal = new(0, int.MaxValue); + + /// + /// Schedule the periodic ping loop, mirroring cppcache + /// ThinClientPoolDM.cpp:269-290. cppcache splits this in two: + /// a long-running pingServer Task that blocks on + /// ping_semaphore_.acquire(), plus a FunctionExpiryTask + /// scheduled by ExpiryTaskManager that releases the semaphore + /// every PingInterval. We collapse to one loop driven by + /// ; stays + /// declared so Phase 1.5's failover path can release it for an + /// immediate probe (then this loop becomes WaitAny(timer, signal)). + /// + /// + /// Interval resolution mirrors cppcache getPingInterval(): + /// per-pool override () + /// wins, otherwise fall back to the system default + /// (, 10 s). + /// Interval <= 0 disables ping entirely (cppcache L286-289). + /// + private void SchedulePingLoop() + { + var pingInterval = attributes.PingInterval ?? poolManager.Cache.CacheProperties.PingInterval; + if (pingInterval > TimeSpan.Zero) + { + logger.LogDebug("ThinClientPoolDM::startBackgroundThreads: Scheduling ping task at {Interval}", pingInterval); + _pingTimer = new PeriodicTimer(pingInterval); + _pingLoop = PingLoopAsync(_backgroundCts.Token); + } + else + { + logger.LogDebug("ThinClientPoolDM::startBackgroundThreads: Not scheduling ping task as ping interval {Interval}", pingInterval); + } + } + + /// + /// Periodic ping loop. Mirrors cppcache + /// ThinClientPoolDM::pingServer + /// (ThinClientPoolDM.cpp:2070-2083): each tick walks every + /// connected endpoint and probes it with MessageType.Ping. + /// + private async Task PingLoopAsync(CancellationToken ct) + { + var initialDelay = TimeSpan.FromSeconds(1); + logger.LogDebug("Starting ping loop for pool {Pool}", name); + try + { + await Task.Delay(initialDelay, ct).ConfigureAwait(false); + + do + { + try + { + await PingServerLocalAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + // One bad tick must not kill the loop — next tick retries. + logger.LogWarning(ex, "Ping tick failed for pool {Pool}", name); + } + } + while (await _pingTimer!.WaitForNextTickAsync(ct).ConfigureAwait(false)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // graceful shutdown via _backgroundCts.Cancel(). + } + logger.LogDebug("Ending ping loop for pool {Pool}", name); + } + /// + /// One ping sweep: probe every connected endpoint and prune the + /// pool's references to any that fall offline. Mirrors cppcache + /// ThinClientPoolDM::pingServerLocal + /// (ThinClientPoolDM.cpp:2028-2040). + /// + /// + /// cppcache holds m_endpointsLock for the whole sweep because + /// std::map isn't safe for concurrent iteration; our + /// is a + /// so a snapshot enumeration is safe and the sweep won't block + /// . + /// + private async Task PingServerLocalAsync(CancellationToken ct) + { + var sweepStopwatch = Stopwatch.StartNew(); + try + { + logger.LogTrace("Ping sweep for pool {Pool}: {Count} endpoint(s)", name, _endpoints.Count); + + foreach (var (_, endpoint) in _endpoints) + { + ct.ThrowIfCancellationRequested(); + + if (!endpoint.IsConnected) + { + // cppcache: pingServerLocal skips disconnected endpoints + // (the test is inside the loop body at L2032). + continue; + } + + var endpointStopwatch = Stopwatch.StartNew(); + await endpoint.PingAsync(this, ct).ConfigureAwait(false); + + if (endpoint.IsConnected) + { + _stats.EndpointPing(endpointStopwatch.Elapsed); + } + else + { + // cppcache (ThinClientPoolDM.cpp:2034-2037): ping flipped + // endpoint's connected_ bit to false → drop pool's + // references on its conns + HA subscription channel. + logger.LogDebug("Ping flipped endpoint {Endpoint} to disconnected; cleaning up.", endpoint.Name); + await RemoveEPConnectionsAsync(endpoint, ct).ConfigureAwait(false); + await RemoveCallbackConnectionAsync(endpoint, ct).ConfigureAwait(false); + } + } + } + finally + { + _stats.PingSweep(sweepStopwatch.Elapsed); + } + } + + #endregion + + #region Connection Manager + + private Task? _connManageLoop; + private readonly SemaphoreSlim _connManageSignal = new(0, int.MaxValue); + + /// + /// Periodic conn-management loop. Mirrors cppcache + /// ThinClientPoolDM::manageConnectionsInternal() + /// (ThinClientPoolDM.cpp:554-575): each tick runs clean-stale, + /// clean-sticky, restore-min in that order. + /// + private async Task ConnManageLoopAsync(CancellationToken ct) + { + // 1s initial delay (cppcache L343-344) pre-opens MinConnections + // within ~1s so the first user op finds an aged conn in the queue + // instead of lazy-opening a fresh one (server hasn't finished + // registering it → RegionDestroyedException on first request). + var initialDelay = TimeSpan.FromSeconds(1); + var interval = attributes.IdleTimeout; + try + { + await Task.Delay(initialDelay, ct).ConfigureAwait(false); + + while (!ct.IsCancellationRequested) + { + int queueSize; + lock (_opConnLock) queueSize = _opConnections.Count; + logger.LogTrace( + "ConnManage tick for pool {Pool}: queue size = {QueueSize}, _poolSize = {PoolSize}", + name, queueSize, Volatile.Read(ref _poolSize)); + + try + { + await CleanStaleConnectionsAsync(ct).ConfigureAwait(false); + await CleanStickyConnectionsAsync(ct).ConfigureAwait(false); + await RestoreMinConnectionsAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + // cppcache L568-574 catch-all + LOGERROR: survive one bad tick. + logger.LogWarning(ex, "ConnManage tick failed for pool {Pool}", name); + } + + await Task.Delay(interval, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + } + + /// + /// One sweep of the idle queue: drop / replace stale connections. + /// Mirrors cppcache ThinClientPoolDM::cleanStaleConnections + /// (ThinClientPoolDM.cpp:402-~500). Called once per + /// tick before + /// . + /// + private async Task CleanStaleConnectionsAsync(CancellationToken ct) + { + var idle = attributes.IdleTimeout; + var loadCond = attributes.LoadConditioningInterval; + var min = attributes.MinConnections; + + var (removelist, savedConns) = ClassifyStaleConns(idle, loadCond, min, ct); + await ReplaceOrDeleteStaleConnsAsync(removelist, min - savedConns, loadCond, ct).ConfigureAwait(false); + } + + /// + /// Walk the queue once and classify each conn as save (re-queue at tail), + /// load-conditioning (age > ), + /// or idle-shrink (unused > AND + /// pool above ). Mirrors cppcache + /// cleanStaleConnections L412-436. + /// + private (List<(TcrConnection Conn, RemovalReason Reason)> Removelist, int SavedConns) + ClassifyStaleConns(TimeSpan idle, TimeSpan loadCond, int min, CancellationToken ct) + { + // Snapshot-bound the sweep (cppcache `availableConns = size()`): + // own re-pushes don't re-inspect; other-thread returns wait next tick. + int snapshot; + lock (_opConnLock) snapshot = _opConnections.Count; + var removelist = new List<(TcrConnection Conn, RemovalReason Reason)>(); + var savedConns = 0; + + for (var i = 0; i < snapshot; i++) + { + ct.ThrowIfCancellationRequested(); + + TcrConnection conn; + lock (_opConnLock) + { + var node = _opConnections.First; + if (node is null) break; // drained early (cppcache `getNoWait → nullptr`) + conn = node.Value; + _opConnections.RemoveFirst(); + } + + // cppcache canItBeDeleted (L2107-2121): idle threshold falls back to + // loadCond when shorter / disabled. Reason split so the close site + // picks the right counter (cppcache lumps both into incLoadCondDisconnects). + var effectiveIdle = (loadCond > TimeSpan.Zero && (loadCond < idle || idle <= TimeSpan.Zero)) + ? loadCond + : idle; + + // TODO Phase 2+ HA: skip conns carrying a subscription queue + // (cppcache canItBeDeleted L2124-2140). Pool-only mode has no + // subscription channel so every conn is eligible. + + if (conn.HasExpired(loadCond)) + { + removelist.Add((conn, RemovalReason.LoadConditioning)); + } + else if (conn.IsIdle(effectiveIdle) && Volatile.Read(ref _poolSize) > min) + { + removelist.Add((conn, RemovalReason.Idle)); + } + else + { + lock (_opConnLock) _opConnections.AddLast(conn); + savedConns++; + } + } + + return (removelist, savedConns); + } + + /// + /// For each classified-stale conn, either rotate it (open a fresh conn while + /// the pool still needs to hit ) or + /// close it outright (pure shrink). Mirrors cppcache + /// cleanStaleConnections L444-499. + /// + private async Task ReplaceOrDeleteStaleConnsAsync( + List<(TcrConnection Conn, RemovalReason Reason)> removelist, + int replaceCount, + TimeSpan loadCond, + CancellationToken ct) + { + foreach (var (conn, reason) in removelist) + { + ct.ThrowIfCancellationRequested(); + + if (replaceCount <= 0) + { + // Pure shrink — savedConns covers Min, close without replacement. + await SafeCloseAsync(conn, "pure-shrink").ConfigureAwait(false); + Interlocked.Decrement(ref _poolSize); _capSlots?.Release(); + switch (reason) + { + case RemovalReason.LoadConditioning: _stats.LoadConditioningDisconnect(); break; + case RemovalReason.Idle: _stats.IdleDisconnect(); break; + } + } + else + { + // Pass `conn` as currentServer hint so SelectEndpoint can return + // the same endpoint and CreatePoolConnectionAsync recycles the conn + // without re-handshaking (cppcache L455-459). + var newConn = await CreatePoolConnectionAsync( + [], currentServer: conn, ct).ConfigureAwait(false); + if (newConn is not null) + { + lock (_opConnLock) _opConnections.AddLast(newConn); + // newConn == conn means recycle; only close on real swap. + if (!ReferenceEquals(newConn, conn)) + { + await SafeCloseAsync(conn, "swap").ConfigureAwait(false); + Interlocked.Decrement(ref _poolSize); _capSlots?.Release(); + _stats.LoadConditioningDisconnect(); + _stats.LoadConditioningConnect(); + } + } + else if (conn.HasExpired(loadCond)) + { + // Replacement failed AND past loadCond → close anyway (doomed). + await SafeCloseAsync(conn, "doomed-expired").ConfigureAwait(false); + Interlocked.Decrement(ref _poolSize); _capSlots?.Release(); + _stats.LoadConditioningDisconnect(); + } + else + { + // Replacement failed, not expired → reset age + push back + // (cppcache L488); else re-elected every sweep. + conn.UpdateCreationTime(); + lock (_opConnLock) _opConnections.AddLast(conn); + } + replaceCount--; + } + } + + // One bad CloseAsync must not abort the sweep (cppcache uses destructor-safe + // `try { GF_SAFE_DELETE } catch (...) {}`). keepAlive:false — transient cleanup. + async ValueTask SafeCloseAsync(TcrConnection c, string context) + { + try { await c.CloseAsync(keepAlive: false, ct).ConfigureAwait(false); } + catch (Exception ex) + { + logger.LogDebug(ex, "CloseAsync threw during CleanStale ({Context}); continuing.", context); + } + } + } + + private enum RemovalReason { LoadConditioning, Idle } + + /// + /// Per-tick sticky-conn cleanup hook. Mirrors cppcache + /// ThinClientPoolDM::cleanStickyConnections + /// (ThinClientPoolDM.cpp:521) — base body is empty {}; + /// overrides to dispatch into + /// . + /// + protected virtual Task CleanStickyConnectionsAsync(CancellationToken ct) + { + _ = ct; + return Task.CompletedTask; + } + + /// + /// Close every conn in that belongs to + /// . Mirrors cppcache + /// ThinClientPoolDM::removeEPConnections(TcrEndpoint*) + /// (ThinClientPoolDM.cpp:2170-2188) — called when ping flips + /// the endpoint to disconnected, so the pool stops handing out its + /// stale conns. + /// + private async Task RemoveEPConnectionsAsync(TcrEndpoint endpoint, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + // Phase 1: scan + remove matching conns under lock (sync, snapshot). + var removed = new List(); + lock (_opConnLock) + { + var node = _opConnections.First; + while (node is not null) + { + var next = node.Next; + if (ReferenceEquals(node.Value.Endpoint, endpoint)) + { + _opConnections.Remove(node); + removed.Add(node.Value); + } + node = next; + } + } + + // Phase 2: close each outside the lock — CloseAsync is wire I/O. + // Pass keepAlive:false (transient cleanup, not pool-wide destroy). + foreach (var conn in removed) + { + await conn.CloseAsync(keepAlive: false, ct).ConfigureAwait(false); + Interlocked.Decrement(ref _poolSize); + _capSlots?.Release(); + _stats.PoolDisconnect(); + } + + // cppcache also `conn_semaphore_.release()`s the manage thread so it + // can re-fill MinConnections. Our ConnManageLoopAsync uses + // PeriodicTimer + fires on its next IdleTimeout tick — no explicit + // wake needed. + + if (removed.Count > 0) + { + logger.LogDebug( + "Removed {Count} conn(s) for endpoint {Endpoint} from pool {Pool}.", + removed.Count, endpoint.Name, name); + } + } + + /// + /// Evict any cached bucket → server mapping pointing at + /// when the failure that just dropped a + /// conn was a transport-level IO / timeout. Mirrors cppcache + /// ThinClientPoolDM::removeEPFromMetadataIfError + /// (ThinClientPoolDM.cpp:1555), which gates on + /// GF_IOERR || GF_TIMEOUT plus a non-null metadata service. + /// + private void RemoveEPFromMetadataIfError(TcrEndpoint endpoint, Exception error) + { + // cppcache filters on GfErrType — translate via .NET exception type. + // Phase 1.5 GfErrType taxonomy will refine this once timeout + + // partial-write paths surface a richer set of exceptions. + if (error is not (IOException or TimeoutException)) return; + // todo + //_clientMetadataService?.RemoveBucketServerLocation(endpoint.Name); + } + + /// + /// HA subscription channel cleanup for . + /// Mirrors cppcache ThinClientPoolDM::removeCallbackConnection + /// (ThinClientPoolDM.hpp:281) — base body is empty {}; + /// overrides to dispatch into the + /// HA-pool's redundancyManager_. + /// + protected virtual Task RemoveCallbackConnectionAsync(TcrEndpoint endpoint, CancellationToken ct) + { + _ = endpoint; + _ = ct; + return Task.CompletedTask; + } + + + /// + /// Open new s until the pool holds at + /// least MinConnections. Mirrors cppcache + /// ThinClientPoolDM::restoreMinConnections(). + /// + /// + /// Called by the conn-management loop scheduled in + /// (Phase 1.5), and + /// indirectly via the request path when + /// 's queue dequeue starves + /// (Phase 1.2). Each iteration delegates to + /// ; that helper does the + /// endpoint selection + handshake. + /// + private async Task RestoreMinConnectionsAsync(CancellationToken ct) + { + var min = attributes.MinConnections; + logger.LogDebug("Restoring minimum connection level for pool {Pool} (min={Min})", name, min); + + // cppcache `limit = 2 * min` (L531) caps the retry budget per tick: + // protects against a race where CreatePoolConnection succeeds but + // another thread closes the conn before _poolSize catches up, + // which would otherwise spin the while-loop indefinitely. + var limit = 2 * min; + var restored = 0; + while (Volatile.Read(ref _poolSize) < min && limit-- > 0) + { + ct.ThrowIfCancellationRequested(); + // Fresh blacklist per warm-up attempt: the failover retry + // within one open is short-lived, no need to carry state across. + var conn = await CreatePoolConnectionAsync( + [], currentServer: null, ct).ConfigureAwait(false); + if (conn is null) + { + // No endpoint reachable this cycle — bail; the next + // conn-management tick will retry. Avoids spinning + // when every endpoint is unhealthy. + break; + } + + // Warm-up path enqueues; sendSyncRequest's starvation path + // (Phase 1.2) will consume the conn directly. Mirrors + // cppcache restoreMinConnections → putInQueue(conn). + lock (_opConnLock) _opConnections.AddLast(conn); + restored++; + _stats.MinPoolSizeConnect(); + } + + int queueSize; + lock (_opConnLock) queueSize = _opConnections.Count; + logger.LogDebug( + "Restored {Restored} connection(s) for pool {Pool}; queue size = {QueueSize}, _poolSize = {PoolSize}", + restored, name, queueSize, Volatile.Read(ref _poolSize)); + } + + /// + /// Open exactly one new . Mirrors + /// cppcache ThinClientPoolDM::createPoolConnection(): + /// select an endpoint (locator or static server list), get-or- + /// create its from the registry, + /// open the connection on it, enqueue. Returns null when + /// no endpoint can currently be reached. + /// + private async Task CreatePoolConnectionAsync( + HashSet excludeServers, + TcrConnection? currentServer = null, + CancellationToken ct = default) + { + // Failover retry loop mirroring cppcache createPoolConnection + // (ThinClientPoolDM.cpp:1725-1802). MaxConnections cap enforced + // via _capSlots: AcquirePoolCapSlotAsync reserves a slot atomically, + // waiting up to FreeConnectionTimeout for a returning conn before + // throwing. finally releases unless ownership transferred to a + // freshly-opened conn (success path clears releaseSlot). + await AcquirePoolCapSlotAsync(ct).ConfigureAwait(false); + + var releaseSlot = true; + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + DnsEndPoint location; + try + { + location = await SelectEndpointAsync(excludeServers, ct).ConfigureAwait(false); + } + catch (GeodeException ex) when (ex is not NoAvailableLocatorsException) + { + // cppcache L1752-1755: generic endpoint-selection fail + // (e.g. NotConnectedException when every static server + // is excluded) → return null, caller bails. Slot + // released by the outer finally. + logger.LogDebug(ex, "Endpoint selection exhausted; bailing"); + return null; + } + // NoAvailableLocators is fatal-client per cppcache + // isFatalClientError (L1749-1751); propagates through the + // outer finally so the slot release still fires. + + logger.LogDebug("Connecting to {Host}:{Port}", location.Host, location.Port); + var endpoint = await AddEPAsync(location, ct).ConfigureAwait(false); + + // cppcache L1760-1765 — currentServer recycle: when SelectEndpoint + // picked the same endpoint the dying conn lives on, don't waste a + // handshake. Reset the load-conditioning clock on the old conn + // and return it. Pool size unchanged (slot stays with currentServer). + if (currentServer is not null && ReferenceEquals(currentServer.Endpoint, endpoint)) + { + logger.LogDebug("Recycling existing connection to {Endpoint}", endpoint.Name); + currentServer.UpdateCreationTime(); + return currentServer; + } + + // Per-endpoint cap (cppcache TcrEndpoint::m_maxConnections). + // Acquired AFTER the recycle check (recycle reuses an existing + // conn → its slot stays). On endpoint-capped, blacklist & loop + // to the next server rather than throwing — pool-wide may + // still have headroom elsewhere. + if (!await endpoint.AcquireSlotAsync(attributes.FreeConnectionTimeout, ct).ConfigureAwait(false)) + { + logger.LogDebug("Endpoint {Endpoint} ConnectionPoolSize cap reached, trying next", endpoint.Name); + excludeServers.Add(location); + continue; + } + var releaseEndpointSlot = true; + try + { + TcrConnection conn; + try + { + conn = await endpoint.CreateNewConnectionAsync(this, false, false, poolManager.Cache.CacheProperties.ConnectTimeout, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is AuthenticationFailedException + or AuthenticationRequiredException + or NotAuthorizedException + or NoAvailableLocatorsException) + { + // cppcache isFatalClientError (L1787-1791): the same failure + // will hit every server in the cluster (auth realm shared, + // locator dead). Propagate; slots released by both finallys. + throw; + } + catch (Exception ex) + { + // cppcache isFatalError ∪ transient (L1772-1786): blacklist + // this server and try the next. Covers SocketException, + // IOException, TimeoutException, NotConnectedException, plus + // CacheServerException ("fatal-but-keep-trying-next" — the + // next server might be healthy). Pool-wide slot stays + // reserved (carries to next iteration); per-EP slot is + // released via the surrounding finally before `continue`. + logger.LogDebug(ex, "Failed to open conn to {Endpoint}, retrying with next", endpoint.Name); + excludeServers.Add(location); + continue; + } + + endpoint.SetConnected(true); + var newSize = Interlocked.Increment(ref _poolSize); + _stats.PoolConnect(); + // cppcache :1707-1711 — pool growing past Min means this conn is + // "extra" load-conditioning capacity rather than warm-up. + if (newSize > attributes.MinConnections) + { + _stats.LoadConditioningConnect(); + } + + // Slot ownership transfers to the freshly-opened conn; the + // pool-wide release fires at close sites, the per-EP release + // rides on conn.DisposeAsync via OwnsEndpointSlot. + conn.OwnsEndpointSlot = true; + releaseEndpointSlot = false; + releaseSlot = false; + return conn; + } + finally + { + if (releaseEndpointSlot) endpoint.ReleaseSlot(); + } + } + } + finally + { + if (releaseSlot) _capSlots?.Release(); + } + } + + + /// + /// Get-or-create the pool's view of 's + /// , taking a TCCM-level reference on + /// first sight. Mirrors cppcache + /// ThinClientPoolDM::addEP(string). + /// + /// + /// Per-pool dedupe: each pool only takes one TCCM ref per unique + /// "host:port", even when + /// is called many times for + /// the same endpoint (the normal case once + /// MinConnections > 1 or after Phase 1.2's request path + /// drives queue starvation). Phase 1.5 may tighten the dedupe race + /// (two concurrent first-sight callers) with + /// ; Phase 1.1 has only the serial + /// conn-management loop, so a missed dedupe is presently + /// unreachable. + /// + private async Task AddEPAsync(DnsEndPoint endpointAddress, CancellationToken ct) + { + if (_endpoints.TryGetValue(endpointAddress, out var cached)) + { + return cached; + } + + var endpoint = await poolManager.Cache.ConnectionManager + .AddRefToTcrEndpointAsync(endpointAddress, this, ct) + .ConfigureAwait(false); + _endpoints.TryAdd(endpointAddress, endpoint); + return endpoint; + } + + /// + /// Pick the next endpoint name (host:port) to open a + /// connection on. Mirrors cppcache + /// ThinClientPoolDM::selectEndpoint + /// (ThinClientPoolDM.cpp:577-632) — the locator vs + /// static-server-list branching point. + /// + /// + /// + /// Priority mirrors cppcache: Locators wins when non-empty, + /// otherwise fall through to Servers. Phase 1.1 fills the + /// static-server branch with the cppcache round-robin cursor + /// (m_server + m_endpointSelectionLock); the locator + /// branch is NIE and the ctor's Locators.Count > 0 + /// guard rejects locator-config at construction time so + /// init fails fast. + /// + /// + /// Phase 1.5 expansion: + /// (a) locator branch via + /// ThinClientLocatorHelper.GetEndpointForNewFwdConnAsync; + /// (b) ISet<ServerLocation> excludeServers parameter + /// for 's retry loop — + /// the static-server branch will gain a do-while loop that skips + /// excluded entries, throwing NotConnectedException once + /// every server is excluded; + /// (c) TcrConnection? currentServer parameter for sticky / + /// refresh paths. + /// + /// + private async Task SelectEndpointAsync( + HashSet excludeServers, + CancellationToken ct = default) + { + // Locator branch (priority) — cppcache ThinClientPoolDM.cpp:577-604. + if (attributes.Locators.Count > 0) + { + return await SelectEndpointFromLocatorAsync(excludeServers, ct).ConfigureAwait(false); + } + + // Static server branch — cppcache ThinClientPoolDM.cpp:605-627. + if (attributes.Servers.Count > 0) + { + return SelectEndpointFromStaticServerList(excludeServers); + } + + // Unreachable: AddGeodeClient options validation rejects pools with + // neither Locators nor Servers. Mirrors cppcache's + // IllegalStateException("No locators or servers provided"). + throw new InvalidOperationException( + $"Pool '{name}' has neither Locators nor Servers configured."); + } + + + /// + /// Pick the next entry from the configured Servers list using + /// the round-robin cursor. Mirrors cppcache + /// ThinClientPoolDM::selectEndpoint static-server branch + /// (ThinClientPoolDM.cpp:605-627). + /// + /// + /// Phase 1.5 will turn this into a do-while that skips entries in + /// excludeServers (cppcache excludeServer helper) and + /// throws NotConnectedException once every server is excluded. + /// + private DnsEndPoint SelectEndpointFromStaticServerList(HashSet excludeServers) + { + // Round-robin cursor under lock, walk up to Count entries and pick + // the first non-excluded. cppcache `selectEndpoint` static branch + // (ThinClientPoolDM.cpp:605-627) — when every entry is excluded, + // throw NotConnectedException so the caller's failover loop bails. + var total = attributes.Servers.Count; + lock (_endpointSelectionLock) + { + for (var i = 0; i < total; i++) + { + if (_server >= total) _server = 0; + var position = _server++; + var server = attributes.Servers[position]; + var endpoint = new DnsEndPoint(server.Host, server.Port); + if (excludeServers.Contains(endpoint)) continue; + + logger.LogDebug( + "ThinClientPoolDM: Selecting endpoint [{Host}:{Port}] from position {Position}", + endpoint.Host, endpoint.Port, position); + return endpoint; + } + } + + throw new NotConnectedException( + $"Pool '{name}': all {total} configured servers are in excludeServers."); + } + #endregion + + /// + /// DM-level send: pick an endpoint and route the request through + /// it. Mirrors cppcache + /// ThinClientPoolDM::sendSyncRequest(request, reply, ...) + /// (ThinClientPoolDM.cpp:1380-1500) — the path every region + /// op (Put / Get / ContainsKey / Destroy) takes when the caller + /// does not pin a specific endpoint. + /// + /// + /// + /// Phase 1.2 slice — single endpoint, no failover, no retry. + /// cppcache wraps in a + /// do-while loop driven by isFatalError classification + + /// selectEndpoint(excludeServers); the retry logic lands in + /// Phase 1.5 once GfErrType taxonomy + excludeServers + /// thread through. + /// + /// + /// and + /// are accepted for cppcache + /// signature parity but currently ignored — failover is Phase 1.5, + /// background-thread stats hooks are Phase 1.5 stats work. + /// + /// + public override Task SendSyncRequestAsync( + TcrMessage request, + bool attemptFailover = true, + bool isBackgroundThread = false, + CancellationToken ct = default) + => SendSyncRequestCoreAsync(request, chunkedResult: null, attemptFailover, isBackgroundThread, ct); + + /// + /// Chunked-reply variant of + /// . + /// + public override Task SendSyncRequestAsync( + TcrMessage request, + TcrChunkedResult chunkedResult, + bool attemptFailover = true, + bool isBackgroundThread = false, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(chunkedResult); + return SendSyncRequestCoreAsync(request, chunkedResult, attemptFailover, isBackgroundThread, ct); + } + + /// + /// Shared body for both + /// overloads — selectEndpoint → addEP → endpoint-pinned send. + /// + private async Task SendSyncRequestCoreAsync( + TcrMessage request, + TcrChunkedResult? chunkedResult, + bool attemptFailover, + bool isBackgroundThread, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + ct.ThrowIfCancellationRequested(); + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDestroyed) != 0, this); + + _ = isBackgroundThread; // Phase 1.5: sticky flag + stats hook. + + logger.LogDebug( + "ThinClientPoolDM::sendSyncRequest{Variant} type={MessageType} txId={TxId}", + chunkedResult is null ? "" : " (chunked)", + request.MessageType, request.TransactionId); + + // Pool ReadTimeout linked onto caller ct for non-query types. + // cppcache:1281-1292 (query-family carries its own wire-level timeout). + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + if (!IsQueryFamilyType(request.MessageType)) + { + linkedCts.CancelAfter(attributes.ReadTimeout); + } + var effectiveCt = linkedCts.Token; + + // #15 in-progress + #16/#17 timing. cppcache bumps m_clientOps at + // entry (`:1272`) and decrements + records #16/#17/#18/#19 at every + // exit (`:1519, 1538`). Stopwatch only records on the success path; + // outer catch classifies non-success exits into #18 / #19 + + // re-throws. Caller-cancellation propagates without classification. + Interlocked.Increment(ref _clientOpsInProgress); + var stopwatch = Stopwatch.StartNew(); + try + { + // Step A — retry frame state. cppcache:1294-1304. + // attemptFailover=false pins to a single attempt regardless of + // pool config (subscription / one-shot callers). + var retriesLeft = attemptFailover ? attributes.RetryAttempts + 1 : 1; + var retryAllEpsOnce = attemptFailover && attributes.RetryAttempts == -1; + var excludeServers = new HashSet(); + var firstTry = true; + Exception? lastError = null; + + // Step B — retry frame. cppcache:1294-1322. + while (retryAllEpsOnce || retriesLeft-- > 0) + { + // Step C — retry bit on resend. cppcache:1309. + if (!firstTry) request = request.UpdateHeaderForRetry(); + + // Step D — query-family timeout doesn't retry. cppcache:1312-1322. + if (lastError is OperationCanceledException && IsQueryFamilyType(request.MessageType)) + { + throw lastError; + } + + // Hoisted for Step F (catch quarantines the failed location). + DnsEndPoint? attemptedLocation = null; + try + { + // Step 1 — pick endpoint. cppcache: selectEndpoint(excludeServers). + attemptedLocation = await SelectEndpointAsync(excludeServers, effectiveCt).ConfigureAwait(false); + + // Step 2 — get-or-create TcrEndpoint (cppcache inlines this in selectEndpoint). + var endpoint = await AddEPAsync(attemptedLocation, effectiveCt).ConfigureAwait(false); + + // Step 3 — endpoint-pinned send. + // TODO Phase 1.5 — sticky / isBGThread put-back flag + // (cppcache:1427-1436: isBGThread || GET_ALL_70 || + // GET_ALL_WITH_CALLBACK || EXECUTE_REGION_FUNCTION_SINGLE_HOP). + // Blocked on StickyManager landing. + var reply = chunkedResult is null + ? await SendRequestToEndpointAsync(request, endpoint, effectiveCt).ConfigureAwait(false) + : await SendRequestToEndpointAsync(request, chunkedResult, endpoint, effectiveCt).ConfigureAwait(false); + + // TODO Phase 4 — PR single-hop metadata refresh + // (cppcache:1484-1508: reply.getMetaDataVersion() + + // request.forSingleHop() → EnqueueForMetadataRefresh). + + // #16 + #17 success record. cppcache:1521, 1541. + _stats.ClientOp(stopwatch.Elapsed); + return reply; + } + catch (Exception ex) when (IsRetryableTransportError(ex, ct)) + { + // Step E — transport-error catch (first-cut taxonomy in + // IsRetryableTransportError; full GfErrType port deferred). + lastError = ex; + logger.LogDebug( + ex, + "ThinClientPoolDM::sendSyncRequest retry-eligible failure (type={MessageType} txId={TxId} endpoint={Endpoint}); attempts left {RetriesLeft}.", + request.MessageType, request.TransactionId, attemptedLocation, retriesLeft); + + // Step F — quarantine the failed endpoint. cppcache:1453. + if (attemptedLocation is not null) + { + excludeServers.Add(attemptedLocation); + } + firstTry = false; + } + } + + // Step G — retries exhausted (cppcache: GfErrType return). + throw lastError ?? new GeodeException( + $"Pool '{name}': all retry attempts exhausted."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Caller-driven cancellation — not an op failure, no #18/#19. + throw; + } + catch (Exception ex) + { + // #18 / #19 classify-on-exit. cppcache:1522-1525, 1542-1545. + if (IsClientOpTimeout(ex)) _stats.ClientOpTimeout(); + else _stats.ClientOpFailure(); + throw; + } + finally + { + Interlocked.Decrement(ref _clientOpsInProgress); + } + } + + /// + /// True for the query / bulk / function message types that cppcache + /// sendSyncRequest treats specially at + /// ThinClientPoolDM.cpp:1281-1292, 1312-1322: they carry their + /// own wire-level messageResponseTimeout part, so the pool + /// skips its own ReadTimeout stamp and never retries them + /// after a timeout (the server already gave up). + /// + private static bool IsQueryFamilyType(MessageType type) => type is + MessageType.Query or + MessageType.QueryWithParameters or + MessageType.PutAll or + MessageType.PutAllWithCallback or + MessageType.ExecuteFunction or + MessageType.ExecuteRegionFunction or + MessageType.ExecuteRegionFunctionSingleHop or + MessageType.ExecuteCqWithIr; + + /// + /// First-cut error taxonomy for the DM retry frame + /// (): true when + /// is a transport-level failure that warrants + /// a retry on (eventually) another endpoint. + /// + /// + /// + /// cppcache's full taxonomy is a GfErrType enum classified per + /// call site (handleEPError, isFatalError, ...); the + /// fully-faithful port is a separate Phase 1.5 prereq. This first cut + /// covers IO / socket / timeout exceptions and the + /// raised by our + /// ReadTimeout-linked CTS (distinguished from caller cancellation by + /// the caller's not being cancelled). + /// + /// + private static bool IsRetryableTransportError(Exception ex, CancellationToken callerCt) => ex switch + { + OperationCanceledException => !callerCt.IsCancellationRequested, + System.Net.Sockets.SocketException => true, + IOException => true, + TimeoutException => true, + _ => false, + }; + + /// + /// True when represents a pool-op timeout for + /// stat classification (cppcache incTimeoutClientOps, #19). + /// Caller-cancellation is filtered upstream by the outer + /// catch (OperationCanceledException) when (ct.IsCancellationRequested), + /// so any reaching here was + /// driven by our linked CTS (ReadTimeout) or a query-family wire + /// timeout — both timeouts. + /// + private static bool IsClientOpTimeout(Exception ex) => + ex is TimeoutException or OperationCanceledException; + /// + public override bool IsMultiUserMode => _isMultiUserMode; + + /// + public override bool IsSecurityOn => _isSecurityOn; + + /// + /// Bump . Mirrors cppcache + /// ThinClientPoolDM::incConnectedEndpoints + /// (ThinClientPoolDM.cpp:2055-2059). Fires from + /// 's broadcast on a real + /// disconnected→connected transition. + /// + public override void IncConnectedEndpoints() + { + var val = Interlocked.Increment(ref _connectedEndpoints); + logger.LogDebug( + "Pool {Pool} has incremented to {Count} the number of connected endpoints", + Name, val); + } + + /// + /// Decrement . Mirrors cppcache + /// ThinClientPoolDM::decConnectedEndpoints + /// (ThinClientPoolDM.cpp:2061-2068). Fires from + /// 's broadcast on a real + /// connected→disconnected transition. + /// + /// + /// cppcache also clears the PDX type registry when the count hits + /// zero AND clear_pdx_registry_ is true — that flag tracks + /// whether any PDX types were registered against now-dead servers. + /// Phase 2+ (PDX serialisation) work; TODO inline. + /// + public override void DecConnectedEndpoints() + { + var val = Interlocked.Decrement(ref _connectedEndpoints); + logger.LogDebug( + "Pool {Pool} has decremented to {Count} the number of connected endpoints", + Name, val); + // TODO Phase 2+: if (val <= 0 && _clearPdxRegistry) ClearPdxTypeRegistry(); + // cppcache ThinClientPoolDM.cpp:2065-2067. + } +} + +/* + + +internal class ThinClientPoolDM( + IServiceProvider serviceProvider, + ILogger logger, + CachePoolOptions xmlPool, + GeodeClientOptions options, + TcrConnectionManager connManager) + : ThinClientBaseDM(connManager, region: null), IPool +{ + + + /// + /// PR single-hop metadata service. Built when + /// ; lifecycle paired + /// with / . + /// + private ClientMetadataService? _clientMetadataService; + + + + + + + + + + + + /// + /// Sticky-transaction connection manager. Built unconditionally in + /// (cppcache ctor L209); cleanup paired with + /// step 6b. protected so the + /// sticky-pool subclass can dispatch sticky-conn ops to it. + /// + protected ThinClientStickyManager? _stickyManager; + + + public bool IsDestroyed => Volatile.Read(ref _isDestroyed) != 0; + + public string Name => xmlPool.Name; + + + + /// + /// Test-only: current pool connection count (cppcache m_poolSize). + /// Bumped in step 4 after a + /// fresh handshakes successfully. + /// + internal int PoolSize => Volatile.Read(ref _poolSize); + + + + /* todo + + // ── Sticky transactions (Phase 6) ── + private bool _isSticky; // m_sticky + + // ── State flags (ThinClientPoolDM.hpp:203-205) ── + private int _destroyPending; // m_destroyPending (Interlocked 0/1) + private int _destroyPendingHADM; // m_destroyPendingHADM (Interlocked 0/1, Phase 2+ HA-pool) + + // ── Identity ── + // cppcache m_memId is pool-scoped (one ID per pool); we currently build one + // per TcrConnection via ClientProxyMembershipIdBuilder. Field reserved for + // the cppcache-faithful pool-shared identity decision (server-side dedup + // risk needs verification before adopting). + private ClientProxyMembershipID? _memId; // m_memId + + // ── Counters (PoolStatistics catalogue progression) ── + private int _numRegions; // m_numRegions (region ref count, Interlocked) + private int _clientOps; // m_clientOps (clientOpsInProgress gauge, Interlocked) + private int _connectedEndpoints; // connected_endpoints_ (Interlocked) + + // ── HA subscription (Phase 2+) ── + private int _primaryServerQueueSize = -1; // m_primaryServerQueueSize (PRIMARY_QUEUE_NOT_AVAILABLE = -1) +* / +} + +*/ diff --git a/src/Geode.Client/Internal/ThinClientPoolHADM.cs b/src/Geode.Client/Internal/ThinClientPoolHADM.cs new file mode 100644 index 0000000..601d4bf --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientPoolHADM.cs @@ -0,0 +1,47 @@ +using Geode.Client.Options; +using Geode.Client.Internal; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// HA-subscription pool variant. Mirrors cppcache ThinClientPoolHADM +/// (cppcache/src/ThinClientPoolHADM.hpp/.cpp) — chosen by the pool +/// factory when is +/// , so the pool owns a redundancy manager that +/// maintains the primary + secondary subscription channels. +/// +/// +/// Phase 2+ entry point. Currently only the +/// override is wired so the +/// per-endpoint cleanup chain runs end-to-end; the full surface +/// (processMarker, getEndpointForNewCallBackConn, the +/// redundancyManager_ field and its lifecycle) lands with Phase 2+ +/// HA / subscription work. +/// +internal sealed class ThinClientPoolHADM( + IServiceProvider serviceProvider, + ILogger logger, + PoolManager poolManager, + string name, + PoolAttributes attributes) + : ThinClientPoolDM(serviceProvider, logger, poolManager, name, attributes) +{ + /// + /// Drop this HA pool's subscription-channel reference to + /// . Mirrors cppcache + /// ThinClientPoolHADM::removeCallbackConnection + /// (ThinClientPoolHADM.cpp:287-289) — delegates to the HA + /// pool's redundancyManager_. + /// + protected override Task RemoveCallbackConnectionAsync(TcrEndpoint endpoint, CancellationToken ct) + { + _ = endpoint; + _ = ct; + // TODO Phase 2+ HA: delegate to ThinClientRedundancyManager.RemoveCallbackConnectionAsync(endpoint, ct) + // once the HA-pool's redundancy manager field + ThinClientRedundancyManager + // class land. + return Task.CompletedTask; + } +} + diff --git a/src/Geode.Client/Internal/ThinClientPoolStickyDM.cs b/src/Geode.Client/Internal/ThinClientPoolStickyDM.cs new file mode 100644 index 0000000..c8b4644 --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientPoolStickyDM.cs @@ -0,0 +1,43 @@ +using Geode.Client.Options; +using Geode.Client.Internal; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Sticky-transaction pool variant. Mirrors cppcache +/// ThinClientPoolStickyDM +/// (cppcache/src/ThinClientPoolStickyDM.hpp/.cpp) — chosen by the +/// pool factory when +/// is , so per-thread sticky connections survive +/// across ops within a transaction. +/// +/// +/// Phase 6 entry point. Currently only the +/// override is wired so the +/// per-tick cleanup chain runs end-to-end; the full surface +/// (setStickyConnection, getConnectionToAnEndPoint, +/// setStickyNull, canItBeDeleted) lands with Phase 6 +/// sticky-tx routing. +/// +internal sealed class ThinClientPoolStickyDM( + IServiceProvider serviceProvider, + ILogger logger, + PoolManager poolManager, + string name, + PoolAttributes attributes) + : ThinClientPoolDM(serviceProvider, logger, poolManager, name, attributes) +{ + /// + /// Dispatch the per-tick sticky-conn aging sweep into + /// . + /// Mirrors cppcache ThinClientPoolStickyDM::cleanStickyConnections + /// (ThinClientPoolStickyDM.cpp:134-140). + /// + protected override Task CleanStickyConnectionsAsync(CancellationToken ct) + { + throw new NotImplementedException(); + // => _stickyManager?.CleanStaleStickyConnectionAsync(ct) ?? Task.CompletedTask; + } + +} diff --git a/src/Geode.Client/Internal/ThinClientRegion.cs b/src/Geode.Client/Internal/ThinClientRegion.cs new file mode 100644 index 0000000..16525bb --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientRegion.cs @@ -0,0 +1,660 @@ +using System.Text.RegularExpressions; +using Geode.Client.Options; +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Concrete proxy-mode region implementation. Mirrors cppcache +/// ThinClientRegion +/// (cppcache/src/ThinClientRegion.hpp:51): inherits the local +/// machinery (here: empty placeholder +/// / ) and adds +/// server roundtrips via a . +/// +/// +/// +/// Phase 1.2 status: , +/// , , and +/// are all end-to-end on the wire. +/// +/// +/// Note the type is non-generic — TKey, TValue live only on +/// the public view, exposed +/// through . The wire path +/// is object-typed; strong typing is compile-time only. +/// +/// +internal sealed partial class ThinClientRegion( + IServiceProvider serviceProvider, + ILogger logger, + //TcrMessageBuilder tcrMessageBuilder, + //SerializationRegistry serializationRegistry, + //EventIdGenerator eventIdGenerator, + string name, + RegionAttributes attributes, + ThinClientBaseDM dm) + : LocalRegion(name, null, attributes) +{ + // One TcrMessageHelper per region — stateless apart from its + // logger, so we instantiate via ActivatorUtilities rather than + // registering as a DI service. Passed positionally to the + // ChunkedXxxResponse handlers so their primary ctor's positional + // arg resolves without TcrMessageHelper needing a DI alias. + private readonly TcrMessageHelper _tcrMessageHelper = + ActivatorUtilities.CreateInstance(serviceProvider); + + /// + /// Shortcut to the owning cache's ; + /// chunked-reply handlers pass it positionally into per-chunk + /// ctors so the + /// registry doesn't need a DI alias. + /// + internal SerializationRegistry SerializationRegistry => dm.Cache.SerializationRegistry; + + + /// + /// Decode a value-bearing part the way cppcache + /// TcrMessage::readObjectPart + /// (cppcache/src/TcrMessage.cpp:469-487) does: + /// + /// + /// + /// lenObj > 0, IsObject=1 → DSCode-tagged; + /// dispatch through + /// (handles NullObj internally). + /// lenObj > 0, IsObject=0 → raw CacheableBytes + /// shortcut. Not used for int32 values; throw until + /// BytesDataConverter lands. + /// lenObj == 0, IsObject=2 → empty byte[] sentinel. + /// Same TODO as above. + /// lenObj == 0, IsObject=0 → key absent → null. + /// + /// + private object? DecodeValuePart(TcrPart part) + { + if (part.Payload.Length == 0) + { + // cppcache readObjectPart empty branch (TcrMessage.cpp:469-487): + // IsObject=0 → key absent → null + // IsObject=2 → empty byte[] sentinel + // other → wire error + return part.IsObject switch + { + 0 => null, + 2 => Array.Empty(), + _ => throw new GeodeException( + $"Unexpected empty value part with IsObject={part.IsObject} " + + $"on Get '{FullPath}'."), + }; + } + + if (part.IsObject == 1) + { + // Standard DSCode-tagged path. SerializationRegistry consumes + // the DSCode byte and dispatches to the converter (NullObj + // returns null). + var reader = new DataInput(part.Payload); + return dm.Cache.SerializationRegistry.ReadObject(reader); + } + + // IsObject=0 + non-empty payload = CacheableBytes shortcut + // (cppcache writeObjectPart's special-case). The shortcut emits + // raw bytes (no DSCode), server side reconstructs as byte[]. + return part.Payload.ToArray(); + } + + [GeneratedRegex(@"^\s*(?:select|import)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex FullQueryRegex(); + + /// + /// Shared OQL routing for region convenience methods + /// ( / ). + /// Mirrors cppcache Region::query + /// (cppcache/src/ThinClientRegion.cpp:518-553): validate the + /// predicate, build select distinct * from <FullPath> this + /// where <predicate> (verbatim if predicate already starts + /// with SELECT/IMPORT), dispatch via the pool DM's + /// . + /// + /// + /// The this alias in FROM is required for WHERE this = … + /// / WHERE this.field to resolve server-side. Non-pool DM + /// routing is deferred (memory pool-only-no-non-pool). + /// <object> mirrors cppcache + /// shared_ptr<Serializable> — row type is untyped at the + /// API boundary; short-circuits to + /// identity. + /// + private async Task> QueryAsync( + string predicate, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(predicate)) + { + logger.LogError("Region query predicate string is empty"); + throw new ArgumentException( + "Region query predicate string is empty.", nameof(predicate)); + } + + logger.LogTrace( + "Region::query: region={RegionPath}, predicate={Predicate}", + FullPath, predicate); + + // cppcache ThinClientRegion.cpp:524-535 — if predicate is already + // a full OQL (starts with SELECT/IMPORT), pass through verbatim; + // otherwise wrap as `select distinct * from this where `. + // The `this` alias is required for `WHERE this = ...` / + // `WHERE this.field` to resolve server-side. + var oql = FullQueryRegex().IsMatch(predicate) + ? predicate + : $"select distinct * from {FullPath} this where {predicate}"; + + // Non-pool DM routing is deferred (memory pool-only-no-non-pool). + if (dm is not ThinClientPoolDM poolDm) + { + throw new NotImplementedException( + "Non-pool DistributionManager query routing is not implemented."); + } + + // mirrors cppcache shared_ptr — row type is + // untyped at the API boundary; TypedResultAdapter short-circuits to + // identity when the IRegion caller asks for object. + var query = poolDm.QueryService.NewQuery(oql); + return await query.ExecuteAsync(ct).ConfigureAwait(false); + } + + /// + /// Extract the entryNotFound i32 from a Destroy + /// . Mirrors cppcache + /// readIntPart applied to the trailing Part + /// (cppcache/src/TcrMessage.cpp:1327): a 4-byte i32 with + /// IsObject=0 — 0 means the entry was destroyed, 1 means + /// the server didn't have it. + /// + /// + /// We grab the last part rather than indexing positionally because + /// the optional version-tag part can shift indices, and Phase 1.2 + /// never reads version tags. When concurrency checks land we'll + /// walk parts in declaration order (flags → versionTag? → + /// prMetaData → entryNotFound) and this helper goes away. + /// + private static int ReadDestroyEntryNotFound(TcrMessage reply) + { + if (reply.Parts.Count == 0) + { + throw new GeodeException( + "Destroy Reply: no parts — expected at least the " + + "entryNotFound int part."); + } + + var part = reply.Parts[^1]; + if (part.Payload.Length != 4) + { + throw new GeodeException( + $"Destroy Reply: expected 4-byte entryNotFound int " + + $"part, got {part.Payload.Length} bytes."); + } + + var reader = new DataInput(part.Payload); + return reader.ReadInt32(); + } + + /// + /// Distribution manager this region dispatches to. Mirrors + /// cppcache ThinClientRegion::m_tcrdm; pool-mode MVP + /// always carries a here. + /// + internal ThinClientBaseDM DistributionManager => dm; + + public override async Task ClearAsync(CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::clear (ThinClientRegion.cpp:767-808) + // + TcrMessageClearRegion ctor (TcrMessage.cpp:1644-1682). Wire layout + // is 2 parts (Region + EventId); callback arg + response-timeout + // optional slots are skipped. + logger.LogTrace("ClearAsync: region={RegionPath}", FullPath); + + var (threadId, sequenceId) = dm.Cache.EventIdGenerator.Next(); + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.ClearRegion) + .AddRegionNamePart(FullPath) + .AddEventIdPart(threadId, sequenceId) + .BuildAsync(ct); + + var reply = await dm.SendSyncRequestAsync(request, ct: ct).ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Reply: + logger.LogDebug("Region {RegionPath} clear sent to server", FullPath); + return; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on Clear '{FullPath}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + + case MessageType.ClearRegionDataError: + throw new GeodeException( + $"Server returned ClearRegionDataError on '{FullPath}'."); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for Clear on '{FullPath}'."); + } + } + + public override async Task ContainsKeyAsync(object key, CancellationToken ct = default) + { + logger.LogTrace("ContainsKeyAsync: region={RegionPath}, key={Key}", FullPath, key); + + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.ContainsKey) + .AddRegionNamePart(FullPath) + .AddKeyPart(dm.Cache, key) + .AddInt32Part(0) // 0 = containsKey, 1 = containsValueForKey (cppcache TcrMessage.cpp:1837) + .BuildAsync(ct); + + var reply = await dm.SendSyncRequestAsync(request, ct: ct).ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Response: + { + var partReader = new DataInput(reply.Parts[0].Payload); + var value = dm.Cache.SerializationRegistry.ReadObject(partReader); + if (value is bool b) + { + return b; + } + throw new GeodeException( + $"ContainsKey on '{FullPath}': expected bool reply, " + + $"got {value?.GetType().Name ?? "null"}."); + } + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on ContainsKey '{FullPath}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for ContainsKey on '{FullPath}'."); + } + } + + public override async Task ExistsValueAsync(string predicate, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::existsValue + // (cppcache/src/ThinClientRegion.cpp:555-566). + var results = await QueryAsync(predicate, ct).ConfigureAwait(false); + return results.Count > 0; + } + + public override async Task> GetAllAsync( + IReadOnlyCollection keys, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::getAllNoThrow_remote + // (ThinClientRegion.cpp:1089-1172) + TcrMessageGetAll ctor + // (TcrMessage.cpp:2470-2502). Wire: 3 parts (Region + keys-as- + // CacheableObjectArray + int(0) callback placeholder). + ArgumentNullException.ThrowIfNull(keys); + if (keys.Count == 0) + { + throw new ArgumentException("GetAll requires at least one key.", nameof(keys)); + } + + logger.LogTrace("GetAllAsync: region={RegionPath}, keyCount={KeyCount}", FullPath, keys.Count); + + // Materialise to IReadOnlyList so the chunked handler can + // index by position (cppcache passes &m_keys to per-chunk VCOPL). + var keyList = keys as IReadOnlyList ?? [.. keys]; + + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.GetAll70) + .AddRegionNamePart(FullPath) + .AddValuePart(dm.Cache, keyList.ToArray()) // CacheableObjectArray DSCode + N elements + .AddInt32Part(0) // callback placeholder + .BuildAsync(ct); + + // addToLocalCache mirrors cppcache LocalRegion::getAll_internal default + // (caller-requested=true) AND caching-enabled. Proxy regions + // (caching=false) collapse to false; VCOPL.FromData step 7 stays + // skipped. Phase 4+ retrofit: flip when client-side caching ships. + var addToLocalCache = Attributes.CachingEnabled; + + var chunkedResult = ActivatorUtilities.CreateInstance( + serviceProvider, _tcrMessageHelper, this, keyList, addToLocalCache); + var reply = await dm + .SendSyncRequestAsync(request, chunkedResult, ct: ct) + .ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Response: + return chunkedResult.Values; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on GetAll '{FullPath}' (keyCount={keys.Count})."); + + case MessageType.GetAllDataError: + throw new GeodeException($"Server returned GetAllDataError on '{FullPath}'."); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for GetAll on '{FullPath}'."); + } + } + + + public override async Task GetAsync(object key, CancellationToken ct = default) + { + logger.LogTrace("GetAsync: region={RegionPath}, key={Key}", FullPath, key); + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.Request) + .AddRegionNamePart(FullPath) + .AddKeyPart(dm.Cache, key) + .BuildAsync(ct); + + var reply = await dm + .SendSyncRequestAsync(request, ct: ct) + .ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Response: + if (reply.Parts.Count == 0) + { + throw new GeodeException($"Get on '{FullPath}': Response with zero parts."); + } + return DecodeValuePart(reply.Parts[0]); + + case MessageType.Exception: + throw new GeodeException($"Server exception on Get '{FullPath}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + + default: + throw new GeodeException($"Unexpected reply type {reply.MessageType} for Get on '{FullPath}'."); + } + } + + public override async Task InvalidateAsync(object key, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::invalidateNoThrow_remote + // (ThinClientRegion.cpp:852-886) + TcrMessageInvalidate ctor + // (TcrMessage.cpp:1896-1932). Wire: 3 parts (Region + Key + EventId); + // callback arg optional slot skipped. + ArgumentNullException.ThrowIfNull(key); + logger.LogTrace("InvalidateAsync: region={RegionPath}, key={Key}", FullPath, key); + + var (threadId, sequenceId) = dm.Cache.EventIdGenerator.Next(); + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.Invalidate) + .AddRegionNamePart(FullPath) + .AddKeyPart(dm.Cache, key) + .AddEventIdPart(threadId, sequenceId) + .BuildAsync(ct); + + var reply = await dm.SendSyncRequestAsync(request, ct: ct).ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Reply: + // cppcache REPLY branch reads versionTag here; Phase 4 + // concurrency-checks territory, dropped for now. + return; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on Invalidate '{FullPath}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + + case MessageType.InvalidateError: + throw new GeodeException( + $"Server returned InvalidateError on '{FullPath}'."); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for Invalidate on '{FullPath}'."); + } + } + + public override async Task PutAllAsync(IReadOnlyDictionary map, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::multiHopPutAllNoThrow_remote + // (ThinClientRegion.cpp:1476-1540) + TcrMessagePutAll ctor + // (TcrMessage.cpp:2354-2422). Wire: 5 + 2N parts (Region + + // EventId + reserved-i32(0) + flags + count + N*(Key, Value)). + ArgumentNullException.ThrowIfNull(map); + if (map.Count == 0) + { + throw new ArgumentException( + "PutAll requires at least one entry.", nameof(map)); + } + + logger.LogTrace("PutAllAsync: region={RegionPath}, entryCount={EntryCount}", FullPath, map.Count); + + // cppcache writeEventIdPart(map.size() - 1): only the base + // (threadId, baseSeq) hits the wire; local sequence counter + // advances by N so subsequent ops dont reuse the per-entry + // logical ids the server derives as baseSeq+i. + var (threadId, baseSequenceId) = dm.Cache.EventIdGenerator.NextRange(map.Count); + + // cppcache TcrMessage.cpp:2396-2404 flags byte: + // 1 = EMPTY (no client-side caching), 2 = concurrency checks. + const int FlagEmpty = 1; + const int FlagConcurrencyChecks = 2; + var flags = 0; + if (!Attributes.CachingEnabled) flags |= FlagEmpty; + if (Attributes.ConcurrencyChecksEnabled) flags |= FlagConcurrencyChecks; + + var builder = TcrMessageBuilder + .Create(serviceProvider, MessageType.PutAll) + .AddRegionNamePart(FullPath) + .AddEventIdPart(threadId, baseSequenceId) + .AddInt32Part(0) // reserved (cppcache writeIntPart(0)) + .AddInt32Part(flags) + .AddInt32Part(map.Count); + foreach (var kvp in map) + { + builder = builder + .AddKeyPart(dm.Cache, kvp.Key) + .AddValuePart(dm.Cache, kvp.Value); + } + var request = await builder.BuildAsync(ct); + + // Chunked reply per-key version tags dropped on the floor + // (RemoveAll/PutAll only ship tags, no values). Handler still + // drains chunks so the reader loop terminates cleanly. + var chunkedResult = ActivatorUtilities.CreateInstance( + serviceProvider, _tcrMessageHelper, this); + var reply = await dm + .SendSyncRequestAsync(request, chunkedResult, ct: ct) + .ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Reply: + return; + + case MessageType.Response: + logger.LogDebug("PutAll on {RegionPath} responded RESPONSE", FullPath); + return; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on PutAll '{FullPath}' (entryCount={map.Count})."); + + case MessageType.PutDataError: + throw new GeodeException( + $"Server returned PutDataError on PutAll '{FullPath}'."); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for PutAll on '{FullPath}'."); + } + } + + public override async Task PutAsync(object key, object value, CancellationToken ct = default) + { + logger.LogTrace("PutAsync: region={RegionPath}, key={Key}", FullPath, key); + var (threadId, sequenceId) = dm.Cache.EventIdGenerator.Next(); + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.Put) // cppcache TcrMessage.cpp:1999 — m_msgType = TcrMessage::PUT + .AddRegionNamePart(FullPath) + .AddNullObjectPart() + .AddInt32Part(0) + .AddKeyPart(dm.Cache, key) + .AddCacheableBooleanPart(false) // isDelta + .AddValuePart(dm.Cache, value) + .AddEventIdPart(threadId, sequenceId) + .BuildAsync(ct); + + var reply = await dm + .SendSyncRequestAsync(request, ct: ct) + .ConfigureAwait(false); + + + switch (reply.MessageType) + { + case MessageType.Reply: + return; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on Put '{FullPath}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for Put on '{FullPath}'."); + } + } + + public override async Task RemoveAllAsync(IReadOnlyCollection keys, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::multiHopRemoveAllNoThrow_remote + // (ThinClientRegion.cpp:1810-1863) + TcrMessageRemoveAll ctor + // (TcrMessage.cpp:2424-2468). Wire: 5 + N parts (Region + + // EventId + flags + NullObj(callback) + count + N*Key). + ArgumentNullException.ThrowIfNull(keys); + if (keys.Count == 0) + { + throw new ArgumentException( + "RemoveAll requires at least one key.", nameof(keys)); + } + + logger.LogTrace("RemoveAllAsync: region={RegionPath}, keyCount={KeyCount}", FullPath, keys.Count); + + var (threadId, baseSequenceId) = dm.Cache.EventIdGenerator.NextRange(keys.Count); + + const int FlagEmpty = 1; + const int FlagConcurrencyChecks = 2; + var flags = 0; + if (!Attributes.CachingEnabled) flags |= FlagEmpty; + if (Attributes.ConcurrencyChecksEnabled) flags |= FlagConcurrencyChecks; + + var builder = TcrMessageBuilder + .Create(serviceProvider, MessageType.RemoveAll) + .AddRegionNamePart(FullPath) + .AddEventIdPart(threadId, baseSequenceId) + .AddInt32Part(flags) + .AddNullObjectPart() // callback arg = null + .AddInt32Part(keys.Count); + foreach (var key in keys) + { + builder = builder.AddKeyPart(dm.Cache, key); + } + var request = await builder.BuildAsync(ct); + + var chunkedResult = ActivatorUtilities.CreateInstance( + serviceProvider, _tcrMessageHelper, this); + var reply = await dm + .SendSyncRequestAsync(request, chunkedResult, ct: ct) + .ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Reply: + case MessageType.Response: + logger.LogDebug( + "RemoveAll on {RegionPath} of {KeyCount} keys acked (type={MessageType})", + FullPath, keys.Count, reply.MessageType); + return; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on RemoveAll '{FullPath}' (keyCount={keys.Count})."); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for RemoveAll on '{FullPath}'."); + } + } + + public override async Task RemoveAsync(object key, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::destroyNoThrow_remote + // (ThinClientRegion.cpp:959-999) + TcrMessageDestroy ctor null-value + // branch (TcrMessage.cpp:1974-1985). Wire: 5 parts + // (Region + Key + NullObj(expectedOldValue) + NullObj(operation) + EventId). + ArgumentNullException.ThrowIfNull(key); + logger.LogTrace("RemoveAsync: region={RegionPath}, key={Key}", FullPath, key); + + var (threadId, sequenceId) = dm.Cache.EventIdGenerator.Next(); + var request = await TcrMessageBuilder + .Create(serviceProvider, MessageType.Destroy) + .AddRegionNamePart(FullPath) + .AddKeyPart(dm.Cache, key) + .AddNullObjectPart() // expectedOldValue = null + .AddNullObjectPart() // operation = null (server treats as plain DESTROY) + .AddEventIdPart(threadId, sequenceId) + .BuildAsync(ct); + + var reply = await dm.SendSyncRequestAsync(request, ct: ct).ConfigureAwait(false); + + switch (reply.MessageType) + { + case MessageType.Reply: + // Reply body layout (cppcache TcrMessage.cpp:1317-1330): + // flags i32 + (versionTag if flags & 0x01) + prMetaData + // + entryNotFound i32. Phase 1.x doesn't drive + // concurrency-checks so flags stays 0, no versionTag, + // and entryNotFound lives in the last part. + var entryNotFound = ReadDestroyEntryNotFound(reply); + return entryNotFound == 0; + + case MessageType.Exception: + throw new GeodeException( + $"Server exception on Remove '{FullPath}': " + + TcrMessageHelper.DecodeExceptionPreview(reply)); + + default: + throw new GeodeException( + $"Unexpected reply type {reply.MessageType} for Remove on '{FullPath}'."); + } + } + + public override async Task SelectValueAsync(string predicate, CancellationToken ct = default) + { + // Mirrors cppcache ThinClientRegion::selectValue + // (cppcache/src/ThinClientRegion.cpp:618-631). + var results = await QueryAsync(predicate, ct).ConfigureAwait(false); + + // cppcache: 0 → null; 1 → results[0]; >1 → QueryException + // ("selectValue has more than one result"). Java's variant + // includes the actual count — kept for diagnostics. + return results.Count switch + { + 0 => null, + 1 => results[0], + _ => throw new GeodeException( + $"selectValue has more than one result (got {results.Count})."), + }; + } + +} diff --git a/src/Geode.Client/Internal/ThinClientStickyManager.cs b/src/Geode.Client/Internal/ThinClientStickyManager.cs new file mode 100644 index 0000000..1ea5d66 --- /dev/null +++ b/src/Geode.Client/Internal/ThinClientStickyManager.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Pool-scoped manager for thread-local sticky connections used by +/// transactions. Mirrors cppcache ThinClientStickyManager +/// (cppcache/src/ThinClientStickyManager.hpp/.cpp). +/// +/// +/// Phase 6 (sticky transactions) entry point. cppcache full surface: +/// getStickyConnection / setStickyConnection / +/// addStickyConnection / cleanStaleStickyConnection / +/// closeAllStickyConnections / canThisConnBeDeleted / +/// releaseThreadLocalConnection / +/// setSingleHopStickyConnection / +/// getSingleHopStickyConnection / getAnyConnection. +/// This class currently has only the lifecycle stub +/// () so +/// can wire the call site +/// now; the body grows with Phase 6 work. +/// +internal sealed class ThinClientStickyManager( + ThinClientPoolDM pool, + ILogger logger) +{ + private readonly ThinClientPoolDM _pool = pool; + private readonly ILogger _logger = logger; + + /// + /// Close every thread-local sticky connection this manager has + /// pinned. Mirrors cppcache + /// ThinClientStickyManager::closeAllStickyConnections() — + /// called from ThinClientPoolDM::destroy (L841) after + /// chunk-processor stop. + /// + public Task CloseAllStickyConnectionsAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + // TODO Phase 6: iterate m_stickyConnList, close + release each + // TLS conn slot. Currently a no-op so + // ThinClientPoolDM.DestroyAsync can wire the call site + // (walking-skeleton). + return Task.CompletedTask; + } + + /// + /// Per-tick sticky-conn aging sweep. Mirrors cppcache + /// ThinClientStickyManager::cleanStaleStickyConnection — + /// called from + /// each conn-management tick. + /// + public Task CleanStaleStickyConnectionAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + // TODO Phase 6: walk m_stickyConnList, close conns whose pinning + // thread is gone / TX is finished. Currently a no-op so + // ThinClientPoolDM.CleanStickyConnectionsAsync can wire the call + // site (walking-skeleton). + return Task.CompletedTask; + } +} diff --git a/src/Geode.Client/Internal/TypeRegistry.cs b/src/Geode.Client/Internal/TypeRegistry.cs new file mode 100644 index 0000000..86b1bd2 --- /dev/null +++ b/src/Geode.Client/Internal/TypeRegistry.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using Geode.Client.Pdx; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Internal; + +/// +/// Default . Scoped (per-cache); mirror of cppcache +/// TypeRegistry (cppcache/include/geode/TypeRegistry.hpp). +/// +internal sealed class TypeRegistry(ILogger logger) : ITypeRegistry +{ + private readonly ConcurrentDictionary _byType = new(); + + public void RegisterPdxType(string? className = null) where T : IPdxSerializable + { + // cppcache flow: TypeRegistry::registerPdxType → SerializationRegistry:: + // addPdxSerializableType → TheTypeMap::bindPdxSerializable + // (SerializationRegistry.cpp:703-715). cppcache key is className + // from obj->getClassName(); we accept it as an optional parameter + // defaulting to typeof(T).FullName. + var entry = new PdxEntry( + ClrType: typeof(T), + ClassName: className ?? typeof(T).FullName!, + Write: (obj, w) => ((T)obj).ToData(w), + Read: r => T.FromData(r)!); + + AddOrThrow(entry); + } + + public void RegisterPdxSerializer(IPdxSerializer serializer, string? className = null) + { + ArgumentNullException.ThrowIfNull(serializer); + + // External path. cppcache uses one global PdxSerializer per cache + // (SerializationRegistry::setPdxSerializer, className-switch + // internally). We modernize to per-type IPdxSerializer and + // collapse into the same _byType dict so wire dispatch has one + // lookup path regardless of intrusive/external origin. + var entry = new PdxEntry( + ClrType: typeof(T), + ClassName: className ?? typeof(T).FullName!, + Write: (obj, w) => serializer.ToData((T)obj, w), + Read: r => serializer.FromData(r)!); + + AddOrThrow(entry); + } + + /// Internal: look up a PDX registration by CLR type. + internal bool TryGetEntry(Type clrType, out PdxEntry entry) => + _byType.TryGetValue(clrType, out entry); + + private void AddOrThrow(PdxEntry entry) + { + if (_byType.TryAdd(entry.ClrType, entry)) return; + + // Mirror cppcache LOGERROR + IllegalStateException + // (SerializationRegistry.cpp:709-713). + logger.LogError("PDX type {ClrType} is already registered.", entry.ClrType.FullName); + throw new InvalidOperationException( + $"PDX type '{entry.ClrType.FullName}' is already registered."); + } + + internal readonly record struct PdxEntry( + Type ClrType, + string ClassName, + Action Write, + Func Read); +} diff --git a/src/Geode.Client/NoAvailableLocatorsException.cs b/src/Geode.Client/NoAvailableLocatorsException.cs new file mode 100644 index 0000000..22c53e1 --- /dev/null +++ b/src/Geode.Client/NoAvailableLocatorsException.cs @@ -0,0 +1,22 @@ +namespace Geode.Client; + +/// +/// Thrown when the client cannot reach any configured locator. Mirrors +/// cppcache NoAvailableLocatorsException; corresponds to +/// GfErrType::GF_CACHE_LOCATOR_EXCEPTION. +/// +/// +/// Treated as a fatal-client failure by the pool's failover retry loop: +/// every server reachability path ultimately depends on a working locator, +/// so retrying on a different host won't help — propagate to the caller. +/// +public class NoAvailableLocatorsException : GeodeException +{ + public NoAvailableLocatorsException() { } + + public NoAvailableLocatorsException(string message) + : base(message) { } + + public NoAvailableLocatorsException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/NotAuthorizedException.cs b/src/Geode.Client/NotAuthorizedException.cs new file mode 100644 index 0000000..d27909d --- /dev/null +++ b/src/Geode.Client/NotAuthorizedException.cs @@ -0,0 +1,24 @@ +namespace Geode.Client; + +/// +/// Thrown when the client authenticated successfully but lacks the +/// permission required for the attempted operation. Mirrors cppcache +/// NotAuthorizedException; corresponds to +/// GfErrType::GF_NOT_AUTHORIZED_EXCEPTION. +/// +/// +/// Treated as a fatal-client failure by the pool's failover retry loop: +/// permissions are server-cluster-wide, so retrying on a different host +/// will fail the same way. Phase 3 security wires this in; MVP never +/// throws it. +/// +public class NotAuthorizedException : GeodeException +{ + public NotAuthorizedException() { } + + public NotAuthorizedException(string message) + : base(message) { } + + public NotAuthorizedException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/NotConnectedException.cs b/src/Geode.Client/NotConnectedException.cs new file mode 100644 index 0000000..5ecb8b5 --- /dev/null +++ b/src/Geode.Client/NotConnectedException.cs @@ -0,0 +1,25 @@ +namespace Geode.Client; + +/// +/// Thrown when the client cannot reach a configured Geode server — +/// either the endpoint is marked disconnected, all connections in the +/// pool are dead, or no usable endpoint could be selected. Mirrors +/// cppcache NotConnectedException; corresponds to +/// GfErrType::GF_NOTCON. +/// +/// +/// Treated as a transient failure by the pool's failover retry loop — +/// excluding the dead endpoint and trying the next server is the +/// expected recovery path. Propagates to the caller only after every +/// configured server has been tried and excluded. +/// +public class NotConnectedException : GeodeException +{ + public NotConnectedException() { } + + public NotConnectedException(string message) + : base(message) { } + + public NotConnectedException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/Options/Cache/CacheDiskPolicy.cs b/src/Geode.Client/Options/Cache/CacheDiskPolicy.cs new file mode 100644 index 0000000..6dbbd50 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheDiskPolicy.cs @@ -0,0 +1,11 @@ +namespace Geode.Client.Options; + +/// +/// region-attributes/disk-policy enumeration. +/// +public enum CacheDiskPolicy +{ + None, + Overflows, + Persist, +} diff --git a/src/Geode.Client/Options/Cache/CacheExpirationAction.cs b/src/Geode.Client/Options/Cache/CacheExpirationAction.cs new file mode 100644 index 0000000..fde31a2 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheExpirationAction.cs @@ -0,0 +1,12 @@ +namespace Geode.Client.Options; + +/// +/// expiration-attributes/action enumeration. +/// +public enum CacheExpirationAction +{ + Invalidate, + Destroy, + LocalInvalidate, + LocalDestroy, +} diff --git a/src/Geode.Client/Options/Cache/CacheExpirationOptions.cs b/src/Geode.Client/Options/Cache/CacheExpirationOptions.cs new file mode 100644 index 0000000..ab91a4d --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheExpirationOptions.cs @@ -0,0 +1,32 @@ +namespace Geode.Client.Options; + +/// +/// Mirrors <expiration-attributes>. Used by the four +/// expiration slots on a region (entry-/region- × idle-time/ttl). +/// +public class CacheExpirationOptions : ICloneable +{ + public CacheExpirationOptions() { } + + public CacheExpirationOptions(CacheExpirationOptions other) + { + Timeout = other.Timeout; + Action = other.Action; + } + + /// timeout attribute (required). + public TimeSpan Timeout { get; set; } + + /// action attribute (optional). + public CacheExpirationAction? Action { get; set; } + + /// Deep clone via copy constructor. + public CacheExpirationOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } +} diff --git a/src/Geode.Client/Options/Cache/CacheHostPortOptions.cs b/src/Geode.Client/Options/Cache/CacheHostPortOptions.cs new file mode 100644 index 0000000..695b7b4 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheHostPortOptions.cs @@ -0,0 +1,42 @@ +namespace Geode.Client.Options; + +/// +/// host-port-type in the XSD — used by +/// <locator> and <server> entries inside a +/// <pool>. +/// +public class CacheHostPortOptions : ICloneable +{ + public CacheHostPortOptions() { } + + public CacheHostPortOptions(CacheHostPortOptions other) + { + Host = other.Host; + Port = other.Port; + } + + /// host attribute (required). + public string Host { get; set; } = string.Empty; + + /// port attribute (required, 0–65535). + public int Port { get; set; } + + /// Deep clone via copy constructor. + public CacheHostPortOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// + /// Validate this entry. Failures are returned as path-prefixed + /// strings (the caller supplies the prefix, e.g. + /// "GeodeClientOptions.Cache.Pools[0].Locators[2]"). + /// + public IEnumerable Validate(string prefix) + { + if (string.IsNullOrWhiteSpace(Host)) + yield return $"{prefix}.Host must not be null, empty, or whitespace."; + + // Port = 0 is rejected (cppcache convention; matches existing validator). + if (Port is < 1 or > 65535) + yield return $"{prefix}.Port must be in the range [1, 65535] (got {Port})."; + } +} diff --git a/src/Geode.Client/Options/Cache/CacheLibraryOptions.cs b/src/Geode.Client/Options/Cache/CacheLibraryOptions.cs new file mode 100644 index 0000000..9ff07cd --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheLibraryOptions.cs @@ -0,0 +1,50 @@ +namespace Geode.Client.Options; + +/// +/// Mirrors library-type in the XSD — +/// <cache-loader>, <cache-listener>, +/// <cache-writer>, <partition-resolver>. +/// +/// +/// These pointers reference a native shared library + entry function +/// used by cppcache to construct the callback. On the .NET side this +/// translates to a delegate / DI-registered type; the field is kept +/// here for parity only and is unlikely to ship in the .NET API. +/// +public class CacheLibraryOptions : ICloneable +{ + public CacheLibraryOptions() { } + + public CacheLibraryOptions(CacheLibraryOptions other) + { + LibraryName = other.LibraryName; + LibraryFunctionName = other.LibraryFunctionName; + } + + /// library-name attribute (optional). + public string LibraryName { get; set; } = string.Empty; + + /// library-function-name attribute (required). + public string LibraryFunctionName { get; set; } = string.Empty; + + /// + /// Deep clone via copy constructor. Virtual so a slot typed as + /// but holding a subclass + /// instance (e.g. ) + /// dispatches to the subclass's Clone and copies its + /// extra members. + /// + public virtual CacheLibraryOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// + /// Validate. No structural rules at this level (cppcache parity + /// stub — see CLAUDE.md "mirror then prune"). Subclasses override + /// to add their own checks. + /// + public virtual IEnumerable Validate(string prefix) + { + yield break; + } +} + diff --git a/src/Geode.Client/Options/Cache/CacheOptions.cs b/src/Geode.Client/Options/Cache/CacheOptions.cs new file mode 100644 index 0000000..7715e12 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheOptions.cs @@ -0,0 +1,77 @@ +namespace Geode.Client.Options; + +/// Declarative cache configuration (pools, regions, PDX) — the per-cache half of the options tree. +public class CacheOptions : ICloneable +{ + public CacheOptions() { } + + public CacheOptions(CacheOptions other) + { + Endpoints = other.Endpoints.Select(e => e.Clone()).ToList(); + RedundancyLevel = other.RedundancyLevel; + Version = other.Version; + Pools = other.Pools.Select(p => p.Clone()).ToList(); + Regions = other.Regions.Select(r => r.Clone()).ToList(); + Pdx = other.Pdx.Clone(); + NamedAttributes = other.NamedAttributes.ToDictionary(kv => kv.Key, kv => kv.Value.Clone()); + } + + /// + /// Inline endpoint list; when non-empty, treated as a synthesized + /// default pool's . + /// + public List Endpoints { get; set; } = []; + + /// Subscription redundancy level; default empty. + public string RedundancyLevel { get; set; } = string.Empty; + + /// Schema version; pinned to "1.0". + public string Version { get; set; } = "1.0"; + + /// Named connection pools, keyed by . + public List Pools { get; set; } = new(); + + /// Top-level regions; can nest via . + public List Regions { get; set; } = new(); + + /// PDX defaults. + public CachePdxOptions Pdx { get; set; } = new(); + + /// Reusable region-attributes templates referenced by . + public Dictionary NamedAttributes { get; set; } = new(); + + /// Deep clone via copy constructor. + public CacheOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate the tree: exactly one of / must be set; region RefIds must resolve to ; recurses into entries. + public IEnumerable Validate(string prefix) + { + var hasEndpoints = Endpoints.Count > 0; + var hasPools = Pools.Count > 0; + if (!hasEndpoints && !hasPools) + yield return $"{prefix} must set either Endpoints or Pools."; + else if (hasEndpoints && hasPools) + yield return $"{prefix}.Endpoints and {prefix}.Pools are mutually exclusive — set one or the other."; + + for (var i = 0; i < Endpoints.Count; i++) + foreach (var f in Endpoints[i].Validate($"{prefix}.Endpoints[{i}]")) + yield return f; + + for (var i = 0; i < Pools.Count; i++) + foreach (var f in Pools[i].Validate($"{prefix}.Pools[{i}]")) + yield return f; + + for (var i = 0; i < Regions.Count; i++) + { + foreach (var f in Regions[i].Validate($"{prefix}.Regions[{i}]")) yield return f; + + // Cross-ref check needs NamedAttributes — done here, not in + // CacheRegionOptions.Validate (which doesn't see siblings). + // Mirrors cppcache CacheParser.cpp:777-786. + var refId = Regions[i].RefId; + if (!string.IsNullOrEmpty(refId) && !NamedAttributes.ContainsKey(refId)) + yield return $"{prefix}.Regions[{i}].RefId='{refId}' does not match any key in {prefix}.NamedAttributes."; + } + } +} diff --git a/src/Geode.Client/Options/Cache/CachePdxOptions.cs b/src/Geode.Client/Options/Cache/CachePdxOptions.cs new file mode 100644 index 0000000..3bc4f53 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CachePdxOptions.cs @@ -0,0 +1,38 @@ +namespace Geode.Client.Options; + +/// PDX options. +public class CachePdxOptions : ICloneable +{ + public CachePdxOptions() { } + + public CachePdxOptions(CachePdxOptions other) + { + IgnoreUnreadFields = other.IgnoreUnreadFields; + ReadSerialized = other.ReadSerialized; + } + + /// + /// Drop fields the local schema doesn't know about on read. + /// + public bool? IgnoreUnreadFields { get; set; } + + /// + /// Keep PDX values serialised on read. + /// + public bool? ReadSerialized { get; set; } + + /// + /// Deep clone. + /// + public CachePdxOptions Clone() => new(this); + + object ICloneable.Clone() => Clone(); + + /// + /// Validate this section. + /// + public IEnumerable Validate(string prefix) + { + yield break; + } +} diff --git a/src/Geode.Client/Options/Cache/CachePersistenceManagerOptions.cs b/src/Geode.Client/Options/Cache/CachePersistenceManagerOptions.cs new file mode 100644 index 0000000..f0da7a6 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CachePersistenceManagerOptions.cs @@ -0,0 +1,35 @@ +namespace Geode.Client.Options; + +/// +/// Mirrors <persistence-manager>. Extends +/// with a free-form +/// <properties><property name= value=> bag. +/// +public class CachePersistenceManagerOptions : CacheLibraryOptions +{ + public CachePersistenceManagerOptions() { } + + public CachePersistenceManagerOptions(CachePersistenceManagerOptions other) : base(other) + { + Properties = new Dictionary(other.Properties); + } + + /// + /// Nested <property name="..." value="..."/> entries. + /// + public Dictionary Properties { get; set; } = new(); + + /// + /// + /// Covariant return — a base-typed slot dispatches virtually to + /// this override and gets the subclass runtime type back. + /// + public override CachePersistenceManagerOptions Clone() => new(this); + + /// + public override IEnumerable Validate(string prefix) + { + foreach (var f in base.Validate(prefix)) yield return f; + // No structural rules for this subclass currently — parity stub. + } +} diff --git a/src/Geode.Client/Options/Cache/CachePoolOptions.cs b/src/Geode.Client/Options/Cache/CachePoolOptions.cs new file mode 100644 index 0000000..eafa37c --- /dev/null +++ b/src/Geode.Client/Options/Cache/CachePoolOptions.cs @@ -0,0 +1,252 @@ +namespace Geode.Client.Options; + +/// +/// Mirrors a <pool> element from cache.xml. Distinct +/// from (which mirrors the global +/// SystemProperties pool defaults) — this one represents a +/// named pool that regions reference via +/// . +/// +/// +/// All attributes are nullable to preserve "not set in XML" vs +/// "explicitly set" — when the field is null, cppcache falls back to its +/// -equivalent global default. +/// +public class CachePoolOptions : ICloneable +{ + + public CachePoolOptions() { } + + public CachePoolOptions(CachePoolOptions other) + { + Name = other.Name; + FreeConnectionTimeout = other.FreeConnectionTimeout; + LoadConditioningInterval = other.LoadConditioningInterval; + MinConnections = other.MinConnections; + MaxConnections = other.MaxConnections; + RetryAttempts = other.RetryAttempts; + IdleTimeout = other.IdleTimeout; + PingInterval = other.PingInterval; + ReadTimeout = other.ReadTimeout; + ServerGroup = other.ServerGroup; + SocketBufferSize = other.SocketBufferSize; + SubscriptionEnabled = other.SubscriptionEnabled; + SubscriptionMessageTrackingTimeout = other.SubscriptionMessageTrackingTimeout; + SubscriptionAckInterval = other.SubscriptionAckInterval; + SubscriptionRedundancy = other.SubscriptionRedundancy; + PrSingleHopEnabled = other.PrSingleHopEnabled; + ThreadLocalConnections = other.ThreadLocalConnections; + MultiuserAuthentication = other.MultiuserAuthentication; + UpdateLocatorListInterval = other.UpdateLocatorListInterval; + Locators = [.. other.Locators.Select(h => h.Clone())]; + Servers = [.. other.Servers.Select(h => h.Clone())]; + } + + object ICloneable.Clone() => Clone(); + + /// Deep clone via copy constructor. + public CachePoolOptions Clone() => new(this); + + /// + /// Validate. Rules migrated from GeodeClientOptionsValidator: + /// non-empty; at least one locator or server entry; + /// >= 0; + /// (when set) >= . Recurses into each + /// . + /// + public IEnumerable Validate(string prefix) + { + if (string.IsNullOrWhiteSpace(Name)) + yield return $"{prefix}.Name must not be null, empty, or whitespace."; + + if (Locators.Count + Servers.Count == 0) + yield return $"{prefix} must have at least one locator or server."; + + // MinConnections == 0 is allowed (cppcache permits 0 = pure lazy). + if (MinConnections < 0) + yield return $"{prefix}.MinConnections must be >= 0 (got {MinConnections})."; + + // MaxConnections == null means "unbounded" — skip the comparison. + if (MaxConnections is int max && max < MinConnections) + yield return $"{prefix}.MaxConnections ({max}) must be >= MinConnections ({MinConnections})."; + + // Mirrors cppcache PoolFactory::setUpdateLocatorListInterval guard + // (PoolFactory.cpp:150): negative durations are rejected; 0 is + // allowed and means "disable the refresh loop". + if (UpdateLocatorListInterval < TimeSpan.Zero) + yield return $"{prefix}.UpdateLocatorListInterval must be >= 0 (got {UpdateLocatorListInterval})."; + + // Mirrors cppcache PoolFactory::setLoadConditioningInterval + // (PoolFactory.cpp:83-86): negative durations are rejected with + // IllegalArgumentException; 0 = disable load conditioning. + if (LoadConditioningInterval < TimeSpan.Zero) + yield return $"{prefix}.LoadConditioningInterval must be >= 0 (got {LoadConditioningInterval})."; + + // Mirrors cppcache PoolFactory::setIdleTimeout + // (PoolFactory.cpp same pattern): negative durations are rejected; + // 0 = disable idle-driven shrink (load conditioning takes over). + if (IdleTimeout < TimeSpan.Zero) + yield return $"{prefix}.IdleTimeout must be >= 0 (got {IdleTimeout})."; + + if (RetryAttempts < 0) + yield return $"{prefix}.RetryAttempts must be >= 0 (got {RetryAttempts})."; + + for (var i = 0; i < Locators.Count; i++) + { + foreach (var f in Locators[i].Validate($"{prefix}.Locators[{i}]")) + yield return f; + } + + for (var i = 0; i < Servers.Count; i++) + { + foreach (var f in Servers[i].Validate($"{prefix}.Servers[{i}]")) + yield return f; + } + } + + /// + /// How long an op may wait for an idle connection when the pool has + /// reached ; throws on timeout. + /// + /// + /// default 10s; must be > . + /// + public TimeSpan FreeConnectionTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// How long a connection can sit unused before the pool may close it + /// to shrink back toward . + /// + /// + /// default 10s. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// How long before a connection is forcibly rotated to spread + /// load across the server cluster, independent of idle status. + /// + /// + /// default 5min; disables load conditioning. + /// + public TimeSpan LoadConditioningInterval { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Pool must have at least one of or per. + /// + public List Locators { get; set; } = []; + + /// + /// Upper cap on pool size; new connection opens are rejected with + /// once the pool reaches + /// this size. + /// + /// + /// default = unbounded. + /// + public int? MaxConnections { get; set; } + + /// + /// Minimum number of connections the pool keeps open; warmed up at init + /// and treated as a floor when cleaning up idle connections. + /// + /// + /// default 1; 0 = pure lazy (open on demand only). + /// + public int MinConnections { get; set; } = 1; + + /// + /// multiuser-authentication. + /// + public bool? MultiuserAuthentication { get; set; } + + /// + /// Pool identifier; required. Regions reference it via + /// . + /// + public string Name { get; set; } = string.Empty; + + /// + /// Same concept as . + /// + public TimeSpan? PingInterval { get; set; } + + /// + /// Enable PR single-hop routing: partitioned-region ops go directly + /// to the bucket primary instead of via a forwarder. + /// + /// + /// default + /// + public bool PrSingleHopEnabled { get; set; } = true; + + /// + /// Duration to wait for a response from a server before timing out + /// the operation and trying another server (if any are available). + /// + /// + /// default 10 s; must be > 0. + /// + public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Failover retry budget per op before the pool throws. + /// + /// + /// default 3; 0 = no retries; must be >= 0. + /// + public int RetryAttempts { get; set; } = 3; + + /// + /// Logical group of servers this pool targets. + /// + public string ServerGroup { get; set; } = string.Empty; + + /// + /// Direct server endpoints for pools that bypass locators. + /// + public List Servers { get; set; } = []; + + /// + /// socket-buffer-size. Same concept as + /// . + /// + public int? SocketBufferSize { get; set; } + + /// + /// subscription-ack-interval. XSD types this as + /// string but cppcache parses as ms. + /// + public int? SubscriptionAckInterval { get; set; } + + /// + /// subscription-enabled. + /// + public bool? SubscriptionEnabled { get; set; } + + /// + /// subscription-message-tracking-timeout. + /// + public int? SubscriptionMessageTrackingTimeout { get; set; } + + /// + /// subscription-redundancy. + /// + public int? SubscriptionRedundancy { get; set; } + + /// + /// thread-local-connections. + /// + public bool? ThreadLocalConnections { get; set; } + + /// + /// How often the pool asks an active locator for the current locator set, + /// so it can pick up newly-added locators and drop dead ones without a client restart; + /// + /// + /// default 5s; disables the refresh loop. + /// + public TimeSpan UpdateLocatorListInterval { get; set; } = TimeSpan.FromSeconds(5); + +} + diff --git a/src/Geode.Client/Options/Cache/CacheRegionAttributesOptions.cs b/src/Geode.Client/Options/Cache/CacheRegionAttributesOptions.cs new file mode 100644 index 0000000..a558430 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheRegionAttributesOptions.cs @@ -0,0 +1,140 @@ +namespace Geode.Client.Options; + +/// +/// Mirrors region-attributes-type. Every attribute is nullable +/// because the XSD defaults are unspecified — null means "fall back to +/// whatever cppcache decides". +/// +public class CacheRegionAttributesOptions : ICloneable +{ + public CacheRegionAttributesOptions() { } + + public CacheRegionAttributesOptions(CacheRegionAttributesOptions other) + { + CachingEnabled = other.CachingEnabled; + CloningEnabled = other.CloningEnabled; + Scope = other.Scope; + InitialCapacity = other.InitialCapacity; + LoadFactor = other.LoadFactor; + ConcurrencyLevel = other.ConcurrencyLevel; + LruEntriesLimit = other.LruEntriesLimit; + DiskPolicy = other.DiskPolicy; + Endpoints = other.Endpoints; + ClientNotification = other.ClientNotification; + PoolName = other.PoolName; + ConcurrencyChecksEnabled = other.ConcurrencyChecksEnabled; + RefId = other.RefId; + RegionTimeToLive = other.RegionTimeToLive?.Clone(); + RegionIdleTime = other.RegionIdleTime?.Clone(); + EntryTimeToLive = other.EntryTimeToLive?.Clone(); + EntryIdleTime = other.EntryIdleTime?.Clone(); + // Virtual Clone() on CacheLibraryOptions dispatches to the + // runtime subtype (e.g. CachePersistenceManagerOptions), + // so polymorphism is preserved without a cast. + PartitionResolver = other.PartitionResolver?.Clone(); + CacheLoader = other.CacheLoader?.Clone(); + CacheListener = other.CacheListener?.Clone(); + CacheWriter = other.CacheWriter?.Clone(); + PersistenceManager = other.PersistenceManager?.Clone(); + } + + /// caching-enabled. + public bool? CachingEnabled { get; set; } + + /// cloning-enabled. + public bool? CloningEnabled { get; set; } + + /// scope. + public CacheScope? Scope { get; set; } + + /// initial-capacity. + public int? InitialCapacity { get; set; } + + /// load-factor. + public float? LoadFactor { get; set; } + + /// concurrency-level. + public int? ConcurrencyLevel { get; set; } + + /// lru-entries-limit. + public int? LruEntriesLimit { get; set; } + + /// disk-policy. + public CacheDiskPolicy? DiskPolicy { get; set; } + + /// endpoints. + public string Endpoints { get; set; } = string.Empty; + + /// client-notification. + public bool? ClientNotification { get; set; } + + /// pool-name — references a + /// in + /// . + public string PoolName { get; set; } = string.Empty; + + /// concurrency-checks-enabled. + public bool? ConcurrencyChecksEnabled { get; set; } + + /// + /// Inner <region-attributes refid="..."> reference. + /// Mirrors the cppcache schema; currently ignored — refid resolution + /// only honours the outer . + /// Wire this in when a consumer actually needs inner-element refid. + /// + public string RefId { get; set; } = string.Empty; + + /// <region-time-to-live>. + public CacheExpirationOptions? RegionTimeToLive { get; set; } + + /// <region-idle-time>. + public CacheExpirationOptions? RegionIdleTime { get; set; } + + /// <entry-time-to-live>. + public CacheExpirationOptions? EntryTimeToLive { get; set; } + + /// <entry-idle-time>. + public CacheExpirationOptions? EntryIdleTime { get; set; } + + /// <partition-resolver>. + public CacheLibraryOptions? PartitionResolver { get; set; } + + /// <cache-loader>. + public CacheLibraryOptions? CacheLoader { get; set; } + + /// <cache-listener>. + public CacheLibraryOptions? CacheListener { get; set; } + + /// <cache-writer>. + public CacheLibraryOptions? CacheWriter { get; set; } + + /// <persistence-manager>. + public CachePersistenceManagerOptions? PersistenceManager { get; set; } + + /// Deep clone via copy constructor. + public CacheRegionAttributesOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate. Delegates to non-null nested options; this class has no own structural rules. + public IEnumerable Validate(string prefix) + { + if (RegionTimeToLive is not null) + foreach (var f in RegionTimeToLive.Validate($"{prefix}.RegionTimeToLive")) yield return f; + if (RegionIdleTime is not null) + foreach (var f in RegionIdleTime.Validate($"{prefix}.RegionIdleTime")) yield return f; + if (EntryTimeToLive is not null) + foreach (var f in EntryTimeToLive.Validate($"{prefix}.EntryTimeToLive")) yield return f; + if (EntryIdleTime is not null) + foreach (var f in EntryIdleTime.Validate($"{prefix}.EntryIdleTime")) yield return f; + if (PartitionResolver is not null) + foreach (var f in PartitionResolver.Validate($"{prefix}.PartitionResolver")) yield return f; + if (CacheLoader is not null) + foreach (var f in CacheLoader.Validate($"{prefix}.CacheLoader")) yield return f; + if (CacheListener is not null) + foreach (var f in CacheListener.Validate($"{prefix}.CacheListener")) yield return f; + if (CacheWriter is not null) + foreach (var f in CacheWriter.Validate($"{prefix}.CacheWriter")) yield return f; + if (PersistenceManager is not null) + foreach (var f in PersistenceManager.Validate($"{prefix}.PersistenceManager")) yield return f; + } +} diff --git a/src/Geode.Client/Options/Cache/CacheRegionOptions.cs b/src/Geode.Client/Options/Cache/CacheRegionOptions.cs new file mode 100644 index 0000000..5ef1d59 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheRegionOptions.cs @@ -0,0 +1,54 @@ +namespace Geode.Client.Options; + +/// +/// Mirrors region-type. Regions can nest via +/// . +/// +public class CacheRegionOptions : ICloneable +{ + public CacheRegionOptions() { } + + public CacheRegionOptions(CacheRegionOptions other) + { + Name = other.Name; + RefId = other.RefId; + Attributes = other.Attributes.Clone(); + ChildRegions = other.ChildRegions.Select(r => r.Clone()).ToList(); + } + + /// name attribute (required). + public string Name { get; set; } = string.Empty; + + /// refid attribute (optional) — copy attributes + /// from a previously-defined region. + public string RefId { get; set; } = string.Empty; + + /// <region-attributes> child. + public CacheRegionAttributesOptions Attributes { get; set; } = new(); + + /// Nested <region> children. + public List ChildRegions { get; set; } = new(); + + /// Deep clone via copy constructor. + public CacheRegionOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// + /// Validate. Rule migrated from GeodeClientOptionsValidator: + /// non-empty. RefId cross-reference is checked at + /// (needs sibling + /// NamedAttributes context). Recurses into + /// and each child region. + /// + public IEnumerable Validate(string prefix) + { + if (string.IsNullOrWhiteSpace(Name)) + yield return $"{prefix}.Name must not be null, empty, or whitespace."; + + foreach (var f in Attributes.Validate($"{prefix}.Attributes")) yield return f; + + for (var i = 0; i < ChildRegions.Count; i++) + foreach (var f in ChildRegions[i].Validate($"{prefix}.ChildRegions[{i}]")) + yield return f; + } +} diff --git a/src/Geode.Client/Options/Cache/CacheScope.cs b/src/Geode.Client/Options/Cache/CacheScope.cs new file mode 100644 index 0000000..a46c4d9 --- /dev/null +++ b/src/Geode.Client/Options/Cache/CacheScope.cs @@ -0,0 +1,12 @@ +namespace Geode.Client.Options; + +/// +/// region-attributes/scope enumeration. Source: +/// cpp-cache-1.0.xsd. +/// +public enum CacheScope +{ + Local, + DistributedNoAck, + DistributedAck, +} diff --git a/src/Geode.Client/Options/GeodeClientOptions.cs b/src/Geode.Client/Options/GeodeClientOptions.cs new file mode 100644 index 0000000..048a391 --- /dev/null +++ b/src/Geode.Client/Options/GeodeClientOptions.cs @@ -0,0 +1,109 @@ +namespace Geode.Client.Options; + +/// +/// User-facing configuration for the Geode client. Bound from the +/// "Geode" section of appsettings.json via +/// IOptions<GeodeClientOptions> and consumed by +/// AddGeodeClient(...). +/// +public class GeodeClientOptions: ICloneable +{ + /// + /// Distributed-system / client name shown in server logs. Mirrors + /// cppcache name. Default empty. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Worker-thread count for cppcache's internal dispatcher. Mirrors + /// cppcache max-fe-threads; default + /// Environment.ProcessorCount * 2. .NET uses + /// System.IO.Pipelines + ThreadPool, so this is very + /// likely a no-op. + /// + public uint ThreadPoolSize { get; set; } = (uint)(Environment.ProcessorCount * 2); + + /// + /// Whether to dedicate a thread to chunked-response handling. + /// Mirrors cppcache enable-chunk-handler-thread; default + /// false. Almost certainly redundant under .NET's async I/O + /// model. + /// + public bool EnableChunkHandlerThread { get; set; } + + /// Connection-pool tuning. See . + public PoolOptions Pool { get; set; } = new(); + + /// TLS / SSL settings. See . + public TlsOptions Tls { get; set; } = new(); + + /// + /// Subscription / durable-client / event-notification settings. + /// See . + /// + public SubscriptionOptions Subscription { get; set; } = new(); + + /// Security / auth settings. See . + public SecurityOptions Security { get; set; } = new(); + + /// Transaction settings. See . + public TxOptions Tx { get; set; } = new(); + + /// Heap-LRU / tombstone settings. See . + public HeapOptions Heap { get; set; } = new(); + + /// PDX-serialisation settings. See . + public PdxOptions Pdx { get; set; } = new(); + + /// + /// Wire-serialisation safety bounds (depth limit etc.). See + /// . No cppcache analogue — + /// added independently to defend against malicious / pathological + /// server payloads. + /// + public SerializationOptions Serialization { get; set; } = new(); + + /// + /// Declarative cache.xml contents — named pools, region + /// trees, PDX defaults. Null when the caller uses the programmatic + /// path (the normal case). + /// + public CacheOptions? Cache { get; set; } + + public GeodeClientOptions() { } + + public GeodeClientOptions(GeodeClientOptions other) + { + Name = other.Name; + ThreadPoolSize = other.ThreadPoolSize; + EnableChunkHandlerThread = other.EnableChunkHandlerThread; + Pool = other.Pool.Clone(); + Tls = other.Tls.Clone(); + Subscription = other.Subscription.Clone(); + Security = other.Security.Clone(); + Tx = other.Tx.Clone(); + Heap = other.Heap.Clone(); + Pdx = other.Pdx.Clone(); + Serialization = other.Serialization.Clone(); + Cache = other.Cache?.Clone(); + } + + /// Deep clone via copy constructor. + public GeodeClientOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate the tree, recursing into each sub-options group. + public IEnumerable Validate(string prefix) + { + foreach (var f in Pool.Validate($"{prefix}.Pool")) yield return f; + foreach (var f in Tls.Validate($"{prefix}.Tls")) yield return f; + foreach (var f in Subscription.Validate($"{prefix}.Subscription")) yield return f; + foreach (var f in Security.Validate($"{prefix}.Security")) yield return f; + foreach (var f in Tx.Validate($"{prefix}.Tx")) yield return f; + foreach (var f in Heap.Validate($"{prefix}.Heap")) yield return f; + foreach (var f in Pdx.Validate($"{prefix}.Pdx")) yield return f; + foreach (var f in Serialization.Validate($"{prefix}.Serialization")) yield return f; + if (Cache is not null) + foreach (var f in Cache.Validate($"{prefix}.Cache")) yield return f; + } +} diff --git a/src/Geode.Client/Options/HeapOptions.cs b/src/Geode.Client/Options/HeapOptions.cs new file mode 100644 index 0000000..4ad3af9 --- /dev/null +++ b/src/Geode.Client/Options/HeapOptions.cs @@ -0,0 +1,48 @@ +namespace Geode.Client.Options; + +/// +/// Heap-LRU and tombstone settings mirrored from cppcache +/// SystemProperties. These are server-side cache-control concepts +/// that cppcache surfaces to the client; on the .NET side they are very +/// likely no-ops and on the deletion shortlist. +/// +public class HeapOptions : ICloneable +{ + public HeapOptions() { } + + public HeapOptions(HeapOptions other) + { + LRULimit = other.LRULimit; + LRUDelta = other.LRUDelta; + TombstoneTimeout = other.TombstoneTimeout; + } + + /// + /// Heap-size threshold in megabytes that triggers LRU eviction. + /// Mirrors cppcache heap-lru-limit; default 0 (= disabled). + /// + public ulong LRULimit { get; set; } + + /// + /// Percentage of entries evicted in one LRU pass. Mirrors cppcache + /// heap-lru-delta; default 10. + /// + public int LRUDelta { get; set; } = 10; + + /// + /// How long a tombstone (deleted-entry marker) is retained before + /// the server reclaims it. Mirrors cppcache tombstone-timeout; + /// default 480 seconds. + /// + public TimeSpan TombstoneTimeout { get; set; } = TimeSpan.FromSeconds(480); + + /// Deep clone via copy constructor. + public HeapOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } +} diff --git a/src/Geode.Client/Options/PdxOptions.cs b/src/Geode.Client/Options/PdxOptions.cs new file mode 100644 index 0000000..2cd0484 --- /dev/null +++ b/src/Geode.Client/Options/PdxOptions.cs @@ -0,0 +1,34 @@ +namespace Geode.Client.Options; + +/// +/// PDX-serialisation settings mirrored from cppcache +/// SystemProperties. PDX is Phase 11 per CLAUDE.md, so the flag +/// below is dormant until then. +/// +public class PdxOptions : ICloneable +{ + + public PdxOptions() { } + + public PdxOptions(PdxOptions other) + { + ClearTypeIdsOnDisconnect = other.ClearTypeIdsOnDisconnect; + } + + object ICloneable.Clone() => Clone(); + + /// Deep clone via copy constructor. + public PdxOptions Clone() => new(this); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } + + /// + /// Whether to flush the cached PDX type-id table when the client disconnects from the server. + /// + public bool ClearTypeIdsOnDisconnect { get; set; } + +} diff --git a/src/Geode.Client/Options/PoolOptions.cs b/src/Geode.Client/Options/PoolOptions.cs new file mode 100644 index 0000000..54a5859 --- /dev/null +++ b/src/Geode.Client/Options/PoolOptions.cs @@ -0,0 +1,80 @@ +namespace Geode.Client.Options; + +/// +/// Connection pool. +/// +public class PoolOptions : ICloneable +{ + public PoolOptions() { } + + public PoolOptions(PoolOptions other) + { + ConnectionPoolSize = other.ConnectionPoolSize; + ConnectTimeout = other.ConnectTimeout; + ConnectWaitTimeout = other.ConnectWaitTimeout; + MaxSocketBufferSize = other.MaxSocketBufferSize; + PingInterval = other.PingInterval; + ShuffleEndpoints = other.ShuffleEndpoints; + BucketWaitTimeout = other.BucketWaitTimeout; + } + + /// + /// Cap on TCP connections per endpoint; the pool stops opening new + /// connections to that endpoint once this many are in use. Sits + /// beneath the pool-wide + /// . + /// + /// + /// default 5; 0 = unlimited (no per-endpoint cap); must be >= 0. + /// + public int ConnectionPoolSize { get; set; } = 5; + + /// + /// Budget for opening a new server connection (TCP connect + Geode + /// handshake combined). + /// + /// + /// default 59s. + /// + public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(59); + + /// Extra wait between failed connects; connect-wait-timeout; default zero (disabled). + /// Per-connection. Linux-only in cppcache (#ifdef __linux, TcrEndpoint.cpp:112-133) — workaround for EPIPE on connect. .NET async sockets don't exhibit it; prune candidate. + public TimeSpan ConnectWaitTimeout { get; set; } = TimeSpan.Zero; + + /// + /// Socket send/receive buffer size; default 65 KiB. + /// + public int MaxSocketBufferSize { get; set; } = 65 * 1024; + + /// + /// Idle keep-alive ping cadence; default 10s. + /// + public TimeSpan PingInterval { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Whether to randomise server-list order at pool construction; default true. + /// + public bool ShuffleEndpoints { get; set; } = true; + + /// + /// How long a partitioned-region op waits for primary-bucket availability; default zero. + /// + public TimeSpan BucketWaitTimeout { get; set; } = TimeSpan.Zero; + + /// Deep clone via copy constructor. + public PoolOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. + public IEnumerable Validate(string prefix) + { + // Defensive — negative TimeSpan for a connect budget makes no sense + // (cppcache parseDurationProperty silently accepts it; we don't). + if (ConnectTimeout < TimeSpan.Zero) + yield return $"{prefix}.ConnectTimeout must be >= 0 (got {ConnectTimeout})."; + + if (ConnectionPoolSize < 0) + yield return $"{prefix}.ConnectionPoolSize must be >= 0 (got {ConnectionPoolSize})."; + } +} diff --git a/src/Geode.Client/Options/SecurityOptions.cs b/src/Geode.Client/Options/SecurityOptions.cs new file mode 100644 index 0000000..0b5ed3f --- /dev/null +++ b/src/Geode.Client/Options/SecurityOptions.cs @@ -0,0 +1,57 @@ +namespace Geode.Client.Options; + +/// +/// Security-related settings mirrored from cppcache +/// SystemProperties. Included for parity during the cppcache +/// audit window. +/// +/// +/// +/// CLAUDE.md uses a flat "Auth": { "Username", "Password" } +/// section for credentials; the Diffie-Hellman fields below come from +/// cppcache and are documented as deprecated upstream. They are very +/// likely to be deleted before Phase 9 (auth) lands. +/// +/// +public class SecurityOptions : ICloneable +{ + public SecurityOptions() { } + + public SecurityOptions(SecurityOptions other) + { + ClientDhAlgo = other.ClientDhAlgo; + ClientKsPath = other.ClientKsPath; + Properties = new Dictionary(other.Properties); + } + + /// + /// Diffie-Hellman algorithm used to encrypt credentials in the + /// handshake. Mirrors cppcache security-client-dhalgo; + /// default empty. Deprecated upstream. + /// + public string ClientDhAlgo { get; set; } = string.Empty; + + /// + /// Path to the client keystore used by the DH credential exchange. + /// Mirrors cppcache security-client-kspath; default empty. + /// Deprecated upstream. + /// + public string ClientKsPath { get; set; } = string.Empty; + + /// + /// Free-form key/value bag forwarded to the server-side auth callback. + /// Mirrors cppcache's security-* property prefix bucket + /// (m_securityPropertiesPtr). + /// + public Dictionary Properties { get; set; } = new(); + + /// Deep clone via copy constructor. + public SecurityOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } +} diff --git a/src/Geode.Client/Options/SerializationOptions.cs b/src/Geode.Client/Options/SerializationOptions.cs new file mode 100644 index 0000000..0e4ebb4 --- /dev/null +++ b/src/Geode.Client/Options/SerializationOptions.cs @@ -0,0 +1,191 @@ +namespace Geode.Client.Options; + +/// +/// Wire-serialisation robustness / safety settings. No cppcache +/// analogue — cppcache trusts the wire stream and lets any nesting +/// depth through. We add a configurable bound so a hostile or buggy +/// server cannot crash the client with a stack-overflow DoS via +/// arbitrarily-nested CacheableArrayList / CacheableHashMap +/// payloads. +/// +/// +/// +/// Modelled on +/// (same default 64, same threat model — untrusted JSON / wire data +/// recursing through nested objects until the runtime stack runs out). +/// Newtonsoft.Json shipped without a limit historically and earned a +/// CVE for it; we'd rather start tight and loosen if real workloads +/// complain than the other way round. +/// +/// +/// Scope. The limit governs recursive descent inside +/// SerializationRegistry.WriteObject / +/// SerializationRegistry.ReadObject — every nested +/// CacheableArrayList / CacheableHashSet / +/// CacheableHashMap / CacheableLinkedList / +/// CacheableStack / CacheableObjectArray / +/// CacheableStringArray layer counts as one level. Scalars and +/// primitive arrays do not contribute (they don't recurse). Realistic +/// payloads almost never exceed 5–10 levels. +/// +/// +/// Behaviour at the limit. Read side throws +/// GeodeException (wire-level error). Write side throws +/// InvalidOperationException (caller bug — probably a cycle in +/// the in-memory graph). Symmetric so a round-trip never produces a +/// payload our own reader would refuse. +/// +/// +public class SerializationOptions : ICloneable +{ + public SerializationOptions() { } + + public SerializationOptions(SerializationOptions other) + { + MaxDepth = other.MaxDepth; + MaxArrayLength = other.MaxArrayLength; + MaxBytesLength = other.MaxBytesLength; + MaxStringLength = other.MaxStringLength; + } + + /// + /// Maximum nested-container depth allowed when serialising or + /// deserialising wire payloads. Default 64 (matches + /// ). + /// Must be >= 1; validated at host build time by + /// GeodeClientOptionsValidator. + /// + public int MaxDepth { get; set; } = 64; + + /// + /// Maximum element count for any single length-prefixed wire + /// payload — [], primitive arrays + /// ([] / [] / + /// …), CacheableObjectArray / CacheableStringArray, + /// and the Tier B-2 collection types + /// (List<T> / HashSet<T> / + /// Dictionary<K,V> / LinkedList<T> / + /// Stack<T>). Default 1,000,000. + /// + /// + /// + /// Threat model. A hostile or buggy server can claim any + /// int32 length in the wire-length prefix. Without a cap, + /// the client reads "I will give you 2,000,000,000 elements" and + /// allocates 8 GB up front — instant OOM. Capping the + /// length on read forces a fast, clear failure + /// (GeodeException) instead. + /// + /// + /// Inclusive bound. The check is length > MaxArrayLength + /// — a payload claiming exactly + /// elements is accepted; one element more is rejected. 0 + /// is the unit-of-measure (allow only empty arrays); + /// rejects negative + /// values. + /// + /// + /// Default rationale. Geode best practice is to keep a + /// single cached value under ~1 MB. A million elements + /// covers int[] up to 4 MB and byte[] up to + /// 1 MB — comfortably above realistic workloads while + /// rejecting hostile gigabyte-scale claims. Tune up for legitimate + /// bulk-data use cases. + /// + /// + /// Write side applies the same cap (caller bug guard) — + /// crossing the limit on encode throws + /// ; on decode it throws + /// GeodeException. Symmetric so we never emit a payload + /// our own reader would refuse. + /// + /// + public int MaxArrayLength { get; set; } = 1_000_000; + + /// + /// Maximum byte count for a single [] + /// payload ( = 46). Default + /// 10,000,000 (10 MB). + /// + /// + /// + /// Split out from because + /// [] serves a different role on Geode: + /// it's the canonical "binary blob" type — file content, + /// serialised objects from another stack, encrypted payloads — + /// which has a wholly different natural size distribution from + /// "a primitive array with many small elements". Typical blob + /// caches range from hundreds of KB to a few MB; the + /// default of 1 M would + /// gratuitously reject those. Tune up to 100 MB or down to + /// 1 MB depending on the workload. + /// + /// + /// Same threat-model and check semantics as + /// : hostile servers can claim any + /// int32 length in the VL-encoded prefix; on read we + /// refuse before allocating. Inclusive bound (length > MaxBytesLength); + /// 0 legal; negative rejected at host build time. + /// + /// + public int MaxBytesLength { get; set; } = 10_000_000; + + /// + /// Maximum length for any single string payload — covers all four + /// CacheableString wire variants (DSCodes 42 / 87 / 88 / + /// 89). Unit is whatever the wire format puts in the length + /// prefix for that variant (modified-UTF-8 byte count for 42, + /// char count for the other three). Default 1,000,000. + /// + /// + /// + /// Threat model matches : the + /// "huge" string variants (88 / 89) carry an i32 length prefix + /// which a hostile server can pin at 2 billion, forcing a + /// multi-gigabyte allocation. Cap on read prevents that. + /// + /// + /// Why a separate limit from . + /// Strings and bulk-data arrays have different "natural" size + /// distributions — a 1 MB JSON-shaped string is not unusual, + /// while a 1 MB-element collection is. Keeping the two + /// configurable independently lets users tune one without + /// loosening the other. Same default for now (1 M) so a stock + /// install behaves consistently. + /// + /// + /// Inclusive bound and validation follow the same rules as + /// : length > MaxStringLength + /// fails; 0 is legal (empty strings only); negative + /// rejected at host build time. + /// + /// + public int MaxStringLength { get; set; } = 1_000_000; + + /// Deep clone via copy constructor. + public SerializationOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// + /// Validate this section. Rules migrated from + /// GeodeClientOptionsValidator: + /// must be >= 1 (zero/negative rejects every payload + /// including top-level scalars); + /// / / + /// must be >= 0 (zero is legal — empty only). + /// + public IEnumerable Validate(string prefix) + { + if (MaxDepth < 1) + yield return $"{prefix}.MaxDepth must be >= 1 (got {MaxDepth})."; + + if (MaxArrayLength < 0) + yield return $"{prefix}.MaxArrayLength must be >= 0 (got {MaxArrayLength})."; + + if (MaxBytesLength < 0) + yield return $"{prefix}.MaxBytesLength must be >= 0 (got {MaxBytesLength})."; + + if (MaxStringLength < 0) + yield return $"{prefix}.MaxStringLength must be >= 0 (got {MaxStringLength})."; + } +} diff --git a/src/Geode.Client/Options/SubscriptionOptions.cs b/src/Geode.Client/Options/SubscriptionOptions.cs new file mode 100644 index 0000000..cf4ecc5 --- /dev/null +++ b/src/Geode.Client/Options/SubscriptionOptions.cs @@ -0,0 +1,95 @@ +namespace Geode.Client.Options; + +/// +/// Subscription, durable-client, and event-notification settings. +/// Mirrors the subscription-related fields of cppcache +/// SystemProperties. The whole group is dormant until Phase 12+ +/// adds CQ / register-interest / event listeners. +/// +public class SubscriptionOptions : ICloneable +{ + public SubscriptionOptions() { } + + public SubscriptionOptions(SubscriptionOptions other) + { + DurableClientId = other.DurableClientId; + DurableTimeout = other.DurableTimeout; + AutoReadyForEvents = other.AutoReadyForEvents; + RedundancyMonitorInterval = other.RedundancyMonitorInterval; + NotifyAckInterval = other.NotifyAckInterval; + NotifyDupCheckLife = other.NotifyDupCheckLife; + ConflateEvents = other.ConflateEvents; + } + + /// + /// Stable client identifier that lets the server retain this client's + /// subscription queue across reconnects. Mirrors cppcache + /// durable-client-id; default empty (= non-durable, server + /// discards the queue on disconnect). + /// + /// + /// Set a stable string (e.g. "order-service-pod-1") to opt into + /// durable subscriptions. Consumed by the + /// ClientProxyMembershipID builder when subscriptions ship in + /// Phase 12+. + /// + public string DurableClientId { get; set; } = string.Empty; + + /// + /// How long the server should retain this client's subscription queue + /// after a disconnect before giving up. Mirrors cppcache + /// durable-timeout; default 300 seconds. Only meaningful when + /// is set. + /// + public TimeSpan DurableTimeout { get; set; } = TimeSpan.FromSeconds(300); + + /// + /// Whether a non-durable client starts receiving subscription events + /// automatically once regions are created. Mirrors cppcache + /// auto-ready-for-events; default true. Set to + /// false to require an explicit "ready" call after wiring up + /// listeners (Phase 12+ API). + /// + public bool AutoReadyForEvents { get; set; } = true; + + /// + /// How often the client checks subscription redundancy (HA queue copy + /// count). Mirrors cppcache redundancy-monitor-interval; + /// default 10 seconds. + /// + public TimeSpan RedundancyMonitorInterval { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Periodic ack cadence for received subscription notifications. + /// Mirrors cppcache notify-ack-interval; default 1 second. + /// + public TimeSpan NotifyAckInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// How long an idle event-id map entry is kept for duplicate-event + /// detection on the subscription channel. Mirrors cppcache + /// notify-dupcheck-life; default 300 seconds. + /// + public TimeSpan NotifyDupCheckLife { get; set; } = TimeSpan.FromSeconds(300); + + /// + /// Per-client event-conflation override sent in the handshake's + /// "overrides" byte. Tristate: null (default) defers to the + /// server-side setting, true forces conflation on for this + /// client, false forces it off. Mirrors cppcache + /// conflate-events's string values "server" / + /// "true" / "false", but bool? is the type-safe + /// way to express the same three states in C#. + /// + public bool? ConflateEvents { get; set; } + + /// Deep clone via copy constructor. + public SubscriptionOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } +} diff --git a/src/Geode.Client/Options/TlsOptions.cs b/src/Geode.Client/Options/TlsOptions.cs new file mode 100644 index 0000000..6213a44 --- /dev/null +++ b/src/Geode.Client/Options/TlsOptions.cs @@ -0,0 +1,55 @@ +namespace Geode.Client.Options; + +/// +/// TLS / SSL configuration. Mirrors cppcache ssl-* settings but +/// will eventually layer on top of System.Net.Security.SslStream +/// (Phase 8) — file paths may be replaced or augmented with +/// X509Certificate2 handles when we get there. +/// +public class TlsOptions : ICloneable +{ + public TlsOptions() { } + + public TlsOptions(TlsOptions other) + { + Enabled = other.Enabled; + KeyStorePath = other.KeyStorePath; + KeyStorePassword = other.KeyStorePassword; + TrustStorePath = other.TrustStorePath; + } + + /// + /// Whether to upgrade the socket with TLS after TCP connect. Mirrors + /// cppcache ssl-enabled; default false. + /// + public bool Enabled { get; set; } + + /// + /// Path to the client keystore (.pem in cppcache). Mirrors cppcache + /// ssl-keystore; default empty. + /// + public string KeyStorePath { get; set; } = string.Empty; + + /// + /// Password protecting the keystore at . + /// Mirrors cppcache ssl-keystore-password; default empty. + /// + public string KeyStorePassword { get; set; } = string.Empty; + + /// + /// Path to the truststore used to validate the server certificate + /// chain. Mirrors cppcache ssl-truststore; default empty. + /// + public string TrustStorePath { get; set; } = string.Empty; + + /// Deep clone via copy constructor. + public TlsOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } +} + diff --git a/src/Geode.Client/Options/TxOptions.cs b/src/Geode.Client/Options/TxOptions.cs new file mode 100644 index 0000000..2ae1796 --- /dev/null +++ b/src/Geode.Client/Options/TxOptions.cs @@ -0,0 +1,33 @@ +namespace Geode.Client.Options; + +/// +/// Transaction-related settings mirrored from cppcache +/// SystemProperties. Out of MVP scope per CLAUDE.md, included +/// only for parity during the audit window. +/// +public class TxOptions : ICloneable +{ + public TxOptions() { } + + public TxOptions(TxOptions other) + { + SuspendedTimeout = other.SuspendedTimeout; + } + + /// + /// How long the server retains a suspended transaction's state + /// before discarding it. Mirrors cppcache suspended-tx-timeout; + /// default 30 seconds. + /// + public TimeSpan SuspendedTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Deep clone via copy constructor. + public TxOptions Clone() => new(this); + object ICloneable.Clone() => Clone(); + + /// Validate this section. No structural rules currently — parity stub. + public IEnumerable Validate(string prefix) + { + yield break; + } +} diff --git a/src/Geode.Client/Pdx/IPdxReader.cs b/src/Geode.Client/Pdx/IPdxReader.cs new file mode 100644 index 0000000..e5dd435 --- /dev/null +++ b/src/Geode.Client/Pdx/IPdxReader.cs @@ -0,0 +1,35 @@ +namespace Geode.Client.Pdx; + +/// Reads PDX fields during deserialization. +public interface IPdxReader +{ + /// Read a field. + bool ReadBoolean(string fieldName); + + /// Read a signed 8-bit field (Java byte). + sbyte ReadByte(string fieldName); + + /// Read an unsigned 16-bit char field (Java char). + char ReadChar(string fieldName); + + /// Read a signed 16-bit field (Java short). + short ReadShort(string fieldName); + + /// Read a signed 32-bit field (Java int). + int ReadInt(string fieldName); + + /// Read a signed 64-bit field (Java long). + long ReadLong(string fieldName); + + /// Read a single-precision float field. + float ReadFloat(string fieldName); + + /// Read a double-precision float field. + double ReadDouble(string fieldName); + + /// Read a string field. + string? ReadString(string fieldName); + + /// Read a date field (Java java.util.Date). + DateTime ReadDate(string fieldName); +} diff --git a/src/Geode.Client/Pdx/IPdxSerializable.cs b/src/Geode.Client/Pdx/IPdxSerializable.cs new file mode 100644 index 0000000..0cc00f2 --- /dev/null +++ b/src/Geode.Client/Pdx/IPdxSerializable.cs @@ -0,0 +1,12 @@ +namespace Geode.Client.Pdx; + +/// Intrusive PDX serialization; the type itself reads / writes its fields. +public interface IPdxSerializable + where TSelf : IPdxSerializable +{ + /// Serialize this instance's fields. + void ToData(IPdxWriter writer); + + /// Reconstruct an instance from the reader. + static abstract TSelf FromData(IPdxReader reader); +} diff --git a/src/Geode.Client/Pdx/IPdxSerializer.cs b/src/Geode.Client/Pdx/IPdxSerializer.cs new file mode 100644 index 0000000..31adb6d --- /dev/null +++ b/src/Geode.Client/Pdx/IPdxSerializer.cs @@ -0,0 +1,11 @@ +namespace Geode.Client.Pdx; + +/// External PDX serializer for types that can't (or shouldn't) implement . +public interface IPdxSerializer +{ + /// Serialize 's fields. + void ToData(T obj, IPdxWriter writer); + + /// Reconstruct a instance from the reader. + T FromData(IPdxReader reader); +} diff --git a/src/Geode.Client/Pdx/IPdxWriter.cs b/src/Geode.Client/Pdx/IPdxWriter.cs new file mode 100644 index 0000000..8b86598 --- /dev/null +++ b/src/Geode.Client/Pdx/IPdxWriter.cs @@ -0,0 +1,35 @@ +namespace Geode.Client.Pdx; + +/// Writes PDX fields during serialization. +public interface IPdxWriter +{ + /// Write a field. + IPdxWriter WriteBoolean(string fieldName, bool value); + + /// Write a signed 8-bit field (Java byte). + IPdxWriter WriteByte(string fieldName, sbyte value); + + /// Write an unsigned 16-bit char field (Java char). + IPdxWriter WriteChar(string fieldName, char value); + + /// Write a signed 16-bit field (Java short). + IPdxWriter WriteShort(string fieldName, short value); + + /// Write a signed 32-bit field (Java int). + IPdxWriter WriteInt(string fieldName, int value); + + /// Write a signed 64-bit field (Java long). + IPdxWriter WriteLong(string fieldName, long value); + + /// Write a single-precision float field. + IPdxWriter WriteFloat(string fieldName, float value); + + /// Write a double-precision float field. + IPdxWriter WriteDouble(string fieldName, double value); + + /// Write a string field. + IPdxWriter WriteString(string fieldName, string? value); + + /// Write a date field (Java java.util.Date). + IPdxWriter WriteDate(string fieldName, DateTime value); +} diff --git a/src/Geode.Client/Pdx/ITypeRegistry.cs b/src/Geode.Client/Pdx/ITypeRegistry.cs new file mode 100644 index 0000000..f6f9de3 --- /dev/null +++ b/src/Geode.Client/Pdx/ITypeRegistry.cs @@ -0,0 +1,11 @@ +namespace Geode.Client.Pdx; + +/// Per-cache PDX type registry; accessed via . +public interface ITypeRegistry +{ + /// Register an intrusive PDX type; defaults to typeof(T).FullName for cross-language identity. + void RegisterPdxType(string? className = null) where T : IPdxSerializable; + + /// Register an external PDX serializer for ; defaults to typeof(T).FullName. + void RegisterPdxSerializer(IPdxSerializer serializer, string? className = null); +} diff --git a/src/Geode.Client/PoolFactory.cs b/src/Geode.Client/PoolFactory.cs new file mode 100644 index 0000000..264e0cc --- /dev/null +++ b/src/Geode.Client/PoolFactory.cs @@ -0,0 +1,217 @@ +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Geode.Client; + +/// +/// Fluent builder for connection pools; obtain via . +/// +/// +/// Mirrors cppcache PoolFactory (cppcache/include/geode/PoolFactory.hpp). +/// Setters mutate the internal ; +/// snapshots them so further factory mutations don't affect already-built pools. +/// +public class PoolFactory +{ + private PoolAttributes _attrs = new(); + private readonly IServiceProvider _serviceProvider; + private readonly PoolManager _poolManager; + + internal PoolFactory( + IServiceProvider serviceProvider, + PoolManager poolManager) + { + _serviceProvider = serviceProvider; + _poolManager = poolManager; + } + + /// Reset all attributes back to defaults. + public PoolFactory Reset() + { + _attrs = new(); + return this; + } + + /// How long an op may wait for a free connection when the pool has hit its max. + public PoolFactory SetFreeConnectionTimeout(TimeSpan connectionTimeout) + { + _attrs.FreeConnectionTimeout = connectionTimeout; + return this; + } + + /// How often connections are rotated to rebalance load across the server cluster. + public PoolFactory SetLoadConditioningInterval(TimeSpan loadConditioningInterval) + { + _attrs.LoadConditioningInterval = loadConditioningInterval; + return this; + } + + /// TCP send/receive buffer size in bytes for each connection in this pool. + public PoolFactory SetSocketBufferSize(int socketBufferSize) + { + _attrs.SocketBufferSize = socketBufferSize; + return this; + } + + /// How long to wait for a server response before failing over to another server. + public PoolFactory SetReadTimeout(TimeSpan readTimeout) + { + _attrs.ReadTimeout = readTimeout; + return this; + } + + /// Minimum connections the pool keeps open (warmed up at init; floor for idle cleanup). + public PoolFactory SetMinConnections(int minConnections) + { + _attrs.MinConnections = minConnections; + return this; + } + + /// Upper cap on pool size; -1 means unbounded. + public PoolFactory SetMaxConnections(int maxConnections) + { + _attrs.MaxConnections = maxConnections; + return this; + } + + /// How long a connection may sit unused before the pool closes it back toward . + public PoolFactory SetIdleTimeout(TimeSpan idleTimeout) + { + _attrs.IdleTimeout = idleTimeout; + return this; + } + + /// Failover retry budget per op; -1 means try every available server before failing. + public PoolFactory SetRetryAttempts(int retryAttempts) + { + _attrs.RetryAttempts = retryAttempts; + return this; + } + + /// Frequency at which idle servers are pinged to keep the client visible. + public PoolFactory SetPingInterval(TimeSpan pingInterval) + { + _attrs.PingInterval = pingInterval; + return this; + } + + /// How often the pool refreshes the locator set from an active locator; disables the loop. + public PoolFactory SetUpdateLocatorListInterval(TimeSpan updateLocatorListInterval) + { + _attrs.UpdateLocatorListInterval = updateLocatorListInterval; + return this; + } + + /// Frequency at which client statistics are sent to the server; disables sending. + public PoolFactory SetStatisticInterval(TimeSpan statisticInterval) + { + _attrs.StatisticInterval = statisticInterval; + return this; + } + + /// Logical group of servers this pool targets; empty string means all servers. + public PoolFactory SetServerGroup(string serverGroup) + { + _attrs.ServerGroup = serverGroup; + return this; + } + + /// Enable server-to-client subscription on this pool. + public PoolFactory SetSubscriptionEnabled(bool subscriptionEnabled) + { + _attrs.SubscriptionEnabled = subscriptionEnabled; + return this; + } + + /// Redundancy level for servers holding subscriptions established by this client. + public PoolFactory SetSubscriptionRedundancy(int subscriptionRedundancy) + { + _attrs.SubscriptionRedundancy = subscriptionRedundancy; + return this; + } + + /// How long subscription messages from a server are tracked to deduplicate events. + public PoolFactory SetSubscriptionMessageTrackingTimeout(TimeSpan subscriptionMessageTrackingTimeout) + { + _attrs.SubscriptionMessageTrackingTimeout = subscriptionMessageTrackingTimeout; + return this; + } + + /// How long to batch subscription event acks before sending to the server. + public PoolFactory SetSubscriptionAckInterval(TimeSpan subscriptionAckInterval) + { + _attrs.SubscriptionAckInterval = subscriptionAckInterval; + return this; + } + + /// When , each thread caches its own pool connection (trades server load for client thread contention). + public PoolFactory SetThreadLocalConnection(bool threadLocalConnection) + { + _attrs.ThreadLocalConnection = threadLocalConnection; + return this; + } + + /// Enable multi-user secure mode (each authenticated user holds its own server-side proxy). + public PoolFactory SetMultiuserSecureMode(bool multiuserSecureMode) + { + _attrs.MultiuserSecureMode = multiuserSecureMode; + return this; + } + + /// Enable partitioned-region single-hop routing (ops go directly to the bucket primary). + public PoolFactory SetPrSingleHopEnabled(bool prSingleHopEnabled) + { + _attrs.PrSingleHopEnabled = prSingleHopEnabled; + return this; + } + + /// TLS SNI proxy host, when servers are fronted by an SNI-capable proxy. + public PoolFactory SetSniProxyHost(string sniProxyHost) + { + _attrs.SniProxyHost = sniProxyHost; + return this; + } + + /// TLS SNI proxy port; paired with . + public PoolFactory SetSniProxyPort(int sniProxyPort) + { + _attrs.SniProxyPort = sniProxyPort; + return this; + } + + /// Append a locator endpoint; a pool may hold locators or servers, not both. + /// A server has already been added. + public PoolFactory AddLocator(string host, int port) + { + _attrs.AddLocator(host, port); + return this; + } + + /// Append a direct server endpoint; a pool may hold locators or servers, not both. + /// A locator has already been added. + public PoolFactory AddServer(string host, int port) + { + _attrs.AddServer(host, port); + return this; + } + + /// Validate, snapshot, register, and connect a new pool under . + /// Current attributes failed validation. + /// A pool is already registered under . + public async Task BuildAsync(string poolName, CancellationToken ct = default) + { + var errors = _attrs.Validate(nameof(PoolAttributes)).ToList(); + if (errors.Count > 0) + { + throw new OptionsValidationException( + nameof(PoolAttributes), typeof(PoolAttributes), errors); + } + + var snapshot = _attrs.Clone(); + var pool = ActivatorUtilities.CreateInstance(_serviceProvider, _poolManager, poolName, snapshot); + _poolManager.AddPool(poolName, pool); + await pool.InitAsync(ct).ConfigureAwait(false); + return pool; + } +} diff --git a/src/Geode.Client/Protocol/CacheableObjectPartList.cs b/src/Geode.Client/Protocol/CacheableObjectPartList.cs new file mode 100644 index 0000000..ea10bd1 --- /dev/null +++ b/src/Geode.Client/Protocol/CacheableObjectPartList.cs @@ -0,0 +1,90 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol; + +/// +/// Base list of object parts shipped back in chunked replies of +/// GetAll / registerInterest. Mirrors cppcache +/// CacheableObjectPartList +/// (cppcache/src/CacheableObjectPartList.hpp); see also +/// the Java side GetAll.ObjectPartList. +/// +/// +/// +/// Phase 1.3.b status: members only, no decoder. Field shape +/// mirrors cppcache 1:1 so the fromData decoder can land +/// without re-shaping. Phase 1.3's only bulk-op consumer +/// (RemoveAll) drops per-key results on the floor — +/// this class exists as the base of +/// for cppcache-shape +/// parity. +/// +/// +/// cppcache derives from DataSerializableFixedId with +/// DSFid.CacheableObjectPartList; Phase 1.3 has no +/// DSFid enum yet, so the fixed-id link lands when the wire +/// decoder does. +/// +/// +// Phase 1.3.b: protected fields below are placeholder shells; the +// wire decoder + ctors that actually populate them land in Phase 4+. +#pragma warning disable CS0169 // field never used — see remarks above +#pragma warning disable CS0414 // field assigned but never used — same +internal class CacheableObjectPartList(RegionInternal region) +{ + /// cppcache m_keys + /// (const std::vector<CacheableKey>*). Keys are + /// object-typed in our codec (cppcache wraps them in + /// CacheableKey which we don't mirror). + protected IReadOnlyList? Keys; + + /// cppcache m_keysOffset + /// (uint32_t*). cppcache uses a pointer so multiple + /// readers can advance the same cursor; we'll switch to an + /// explicit ref int parameter on FromData when the + /// decoder lands. + protected int KeysOffset; + + /// cppcache m_values + /// (HashMapOfCacheable) — key→value map populated by + /// FromData. Value type ? until PDX / + /// custom serialisation lands (Phase 2). + protected Dictionary? Values; + + /// cppcache m_exceptions + /// (HashMapOfException) — key→exception map for the + /// failed entries in a partial-result reply. cppcache wraps + /// the wire-decoded class name in CacheServerException + /// (or NotAuthorizedException); our port unifies on + /// . + protected Dictionary? Exceptions; + + /// cppcache m_resultKeys + /// (std::shared_ptr<std::vector<CacheableKey>>). + /// Keys actually returned by the server (subset of + /// for GetAll partial paths). + protected List? ResultKeys; + + /// cppcache m_region + /// (ThinClientRegion*) — back-ref so the decoder can + /// look up region attributes (cachingEnabled, concurrency + /// checks) and dispatch local-cache writes (putLocal). + /// Typed as (not public + /// ) so Phase 4+ PutLocal calls + /// reach without a downcast. + protected RegionInternal Region { get; } = region; + + /// cppcache m_updateCountMap + /// (MapOfUpdateCounters*) — per-key update counters + /// for the client-side caching tracker. Phase 4+. + protected object? UpdateCountMap; + + /// cppcache m_destroyTracker — destroy-op + /// tracker id for transactional GetAll. Phase 4+. + protected int DestroyTracker; + + /// cppcache m_addToLocalCache — whether the + /// decoded entries should be merged into the client's local + /// cache. Phase 4+ when client-side caching lands. + protected bool AddToLocalCache; +} diff --git a/src/Geode.Client/Protocol/ClientProxyMembershipID.cs b/src/Geode.Client/Protocol/ClientProxyMembershipID.cs new file mode 100644 index 0000000..ef169a9 --- /dev/null +++ b/src/Geode.Client/Protocol/ClientProxyMembershipID.cs @@ -0,0 +1,119 @@ +using Geode.Client.Protocol.Serialization; + +namespace Geode.Client.Protocol; + +/// +/// Wire-decoded representation of a Geode internal-distributed-member +/// identity. Used as the member-id payload inside a +/// 's HAS_MEMBER_ID / +/// HAS_PREVIOUS_MEMBER_ID branches. Mirrors cppcache +/// ClientProxyMembershipID +/// (cppcache/src/ClientProxyMembershipID.hpp). +/// +/// +/// +/// Not the same as . +/// The builder produces the bytes we send in the handshake; +/// this class decodes member-id bytes the server sends back +/// inside version tags. Same logical entity, opposite directions. +/// cppcache uses one class for both via toData/fromData; +/// our port split them because the encode path (handshake) was +/// already in place before the decode path (version stamps) was +/// needed. +/// +/// +/// Phase 1.3.b status: empty shell. Fields mirror cppcache +/// m_*; is NIE. Phase 4+ +/// fills in the decoder body — needs to read host address bytes, +/// hostPort, dsName, uniqueTag, vmViewId per the Java +/// InternalDistributedMember wire format. +/// +/// +/// DSMemberForVersionStamp base dropped. cppcache derives +/// from DSMemberForVersionStamp (which is itself +/// CacheableKey); that lets MemberListForVersionStamp +/// store it polymorphically. Our MemberListForVersionStamp +/// stub takes ?, so the inheritance isn't +/// needed until Phase 4+. +/// +/// +// Phase 1.3.b: fields below are placeholder shells; decoder body +// lands Phase 4+. +#pragma warning disable CS0169 +#pragma warning disable CS0414 +#pragma warning disable CS0649 +internal sealed class ClientProxyMembershipID(SerializationRegistry serializationRegistry) +{ + /// cppcache kVmKindLoner = 13. + private const byte VM_KIND_LONER = 13; + + /// cppcache kDcPort = 12334 — dummy port used in + /// readEssentialData's initObjectVars call. + private const int DC_PORT = 12334; + + private string _memIdStr = ""; + private string _clientId = ""; + private string _dsName = ""; + private uint _hostPort; + private byte[] _hostAddr = []; + private string _uniqueTag = ""; + private string _hashKey = ""; + private uint _vmViewId; + + /// + /// Read the "essential" member-id payload (used inside a + /// VersionTag's member-id slot — a leaner subset than full + /// fromData). Mirrors cppcache + /// ClientProxyMembershipID::readEssentialData + /// (cppcache/src/ClientProxyMembershipID.cpp:222-256). + /// + /// + /// Wire format: + /// + /// ArrayLen length (i32 VL-encoded) + /// length bytes hostAddress (raw IP bytes) + /// i32 hostPort + /// u8 flag (ignored) + /// u8 vmKind (== VM_KIND_LONER(13) → loner branch) + /// CacheableString uniqueTag (loner) OR vmViewIdStr (non-loner; parse to int) + /// CacheableString dsName + /// + /// cppcache then calls initObjectVars with the parsed + /// values + dummies; we inline the field assignments. + /// + internal void ReadEssentialData(DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + + var length = reader.ReadArrayLen(); + _hostAddr = length > 0 ? reader.ReadBytesOnly(length).ToArray() : []; + _hostPort = (uint)reader.ReadInt32(); + reader.AdvanceCursor(1); // skip flag + var vmKind = reader.ReadByte(); + + string? uniqueTag = null; + uint vmViewId = 0; + if (vmKind == VM_KIND_LONER) + { + uniqueTag = serializationRegistry.ReadObject(reader) as string; + } + else + { + var vmViewIdStr = serializationRegistry.ReadObject(reader) as string; + if (!uint.TryParse(vmViewIdStr, out vmViewId)) + { + throw new GeodeException( + $"ClientProxyMembershipID.ReadEssentialData: " + + $"could not parse vmViewId '{vmViewIdStr}'."); + } + } + + var dsName = serializationRegistry.ReadObject(reader) as string ?? ""; + + _dsName = dsName; + _uniqueTag = uniqueTag ?? ""; + _vmViewId = vmViewId; + _ = vmKind; // stored implicitly via the branch above + _ = DC_PORT; // reserved for the full initObjectVars port (Phase 4+) + } +} diff --git a/src/Geode.Client/Protocol/ClientProxyMembershipIdBuilder.cs b/src/Geode.Client/Protocol/ClientProxyMembershipIdBuilder.cs new file mode 100644 index 0000000..e8c352a --- /dev/null +++ b/src/Geode.Client/Protocol/ClientProxyMembershipIdBuilder.cs @@ -0,0 +1,185 @@ +using System.Buffers; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Geode.Client.Internal; +using Geode.Client.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol; + +/// +/// Generates the inner identity blob of a Geode ClientProxyMembershipID +/// ??i.e. the bytes carried in step 6c of the handshake. Mirrors cppcache +/// ClientProxyMembershipIDFactory::create + +/// ClientProxyMembershipID::initObjectVars. +/// +/// +/// +/// The blob is a serialised Java InternalDistributedMember +/// (DataSerializableFixedID = 92), not a serialised +/// ClientProxyMembershipID. The outer ClientProxyMembershipID +/// framing (FixedIDByte + DSFid 38 + identity blob + i32 uniqueId) is +/// added by TcrConnection.HandshakeAsync step 6 ??this builder only +/// emits the identity bytes. +/// +/// +/// Registered as scoped in AddCore ??one builder per +/// cache. Identity is cache-scoped because +/// (cluster name) participates in the blob; two caches with different +/// configured names must yield different identity bytes. Reads +/// through +/// so named registrations route correctly (plain IOptions<T> +/// would always return the unnamed default and alias clusters together). +/// The result is still cached after the first +/// call since inputs (hostname, IP, PID, options) are immutable per cache. +/// +/// +internal sealed class ClientProxyMembershipIdBuilder(IServiceProvider serviceProvider, string name) +{ + // === cppcache hardcoded values (ClientProxyMembershipID.cpp:31-33) ====== + private const byte InternalDistributedMemberDsfid = 92; + private const sbyte VmKindLoner = 13; + private const int DcPort = 12334; + + /// + /// Per-cache unique tag ??generated once in this builder's ctor. + /// Mirrors cppcache ClientProxyMembershipIDFactory::randString_ + /// (ClientProxyMembershipIDFactory.cpp:35-56), which is an + /// instance member built afresh inside each + /// CacheImpl's factory ctor. Format: + /// "Native_" + 10 random alphanumerics + ProcessId. + /// + /// + /// + /// Why per-cache, not process-static. The server dedups + /// write events by (clientId, threadId, sequenceId) in its + /// ClientHealthMonitor. clientId is derived from + /// this tag. If the tag is process-static, two + /// instances in the same process + /// share one client identity from the server's perspective, and + /// each cache's seq-counter (which resets to 0 on cache build) + /// will collide with the previous cache's events on the + /// seq=1, 2, 3... values ??server silently drops the + /// "duplicates". cppcache parity (instance member) sidesteps the + /// whole issue: each cache has its own random tag, so clientIds + /// differ and the dedup triple is naturally unique per cache. + /// + /// + private readonly string _uniqueTag = GenerateUniqueTag(); + + //private readonly GeodeClientOptions _options = scopeContext.Options; + + /// + /// Cached identity bytes. Inputs are immutable for the lifetime of this + /// builder, so we compute once and reuse on subsequent calls. + /// + private byte[]? _identity; + + /// + /// Build the identity blob. Idempotent ??repeated calls return the same + /// byte array reference. + /// + public byte[] Build() + { + if (_identity is not null) + { + return _identity; + } + + using var w = ActivatorUtilities.CreateInstance(serviceProvider); + + // Outer framing: this is a serialised InternalDistributedMember. + w.WriteByte(DSCode.FixedIDByte); + w.WriteByte(InternalDistributedMemberDsfid); + + // Host address: raw IP bytes (4 for IPv4, 16 for IPv6) prefixed + // with varint length via WriteBytes. + w.WriteBytes(ResolveHostAddress()); + + // SyncCounter ??reconnect counter; fresh process = 0. + w.WriteInt32(0); + + // Hostname ??DSCode-tagged string (server reads via + // StaticSerialization.readString). + w.WriteString(Dns.GetHostName()); + + // SplitBrainFlag ??false. cppcache hardcodes 0 in the relevant ctor. + w.WriteSByte(0); + + // DcPort ??distributed-cache port; cppcache hardcodes 12334. + w.WriteInt32(DcPort); + + // vPID ??process ID, lets the server distinguish co-tenant clients. + w.WriteInt32(Environment.ProcessId); + + // vmKind = LONER (13) ??we are not a Geode peer / locator / admin. + w.WriteSByte(VmKindLoner); + + // RoleArrayLength ??no roles. Varint encoding (matches server's + // StaticSerialization.readStringArray length sentinel for empty/null). + w.WriteArrayLen(0); + + // dsName ??distributed system name; usually "" for clients. + w.WriteString(name); + + // uniqueTag ??randomly generated per cache (see _uniqueTag doc). + w.WriteString(_uniqueTag); + + // Durable subscription metadata. Server's MemberIdentifierImpl.toData + // / fromDataPre_GFE_9_0_0_0 reads BOTH unconditionally, so we must + // write them every time: + // - empty string + 300 (server's documented default) for non-durable + // - configured values for durable + // The previous "if (durable) throw; else skip" path corrupted the + // wire because the server then read the trailing Version bytes as + // string contents, hitting "Unknown header byte 0". + //var sub = ""; // todo + w.WriteString(""); + w.WriteInt32(30); + + // Trailing protocol-version stamp (compressed ordinal). + ProtocolVersion.Current.WriteTo(w); + + _identity = w.WrittenSpan.ToArray(); + return _identity; + } + + /// + /// Resolve the local hostname's first IP and return its raw bytes + /// (4 for IPv4, 16 for IPv6). Mirrors cppcache's + /// resolver.resolve(hostname, "0") followed by taking the first + /// endpoint's address ??no filtering by family. + /// + private static byte[] ResolveHostAddress() + { + var hostname = Dns.GetHostName(); + var addresses = Dns.GetHostAddresses(hostname); + if (addresses.Length == 0) + { + throw new InvalidOperationException( + $"No IP address resolved for local hostname '{hostname}'."); + } + return addresses[0].GetAddressBytes(); + } + + /// + /// Generate the per-cache unique tag. Format matches cppcache + /// ClientProxyMembershipIDFactory ctor exactly so server-side + /// log scraping / tooling is interchangeable. + /// + private static string GenerateUniqueTag() + { + const string alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + + var sb = new StringBuilder(capacity: 7 + 10 + 10); + sb.Append("Native_"); + for (int i = 0; i < 10; i++) + { + sb.Append(alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)]); + } + sb.Append(Environment.ProcessId); + return sb.ToString(); + } +} diff --git a/src/Geode.Client/Protocol/DSCode.cs b/src/Geode.Client/Protocol/DSCode.cs new file mode 100644 index 0000000..2b84876 --- /dev/null +++ b/src/Geode.Client/Protocol/DSCode.cs @@ -0,0 +1,111 @@ +namespace Geode.Client.Protocol; + +/// +/// Geode DataSerializable type code (single u8 on the wire). +/// +/// +/// +/// Mirrors cppcache/include/geode/internal/DSCode.hpp's +/// enum class DSCode. Each constant is the leading byte that +/// tags the next bytes as a particular built-in type, e.g. a Part +/// payload starting 0x57 0x00 0x05 'h' 'e' 'l' 'l' 'o' is a +/// (87) of length 5. +/// +/// +/// On the wire =1 means "payload begins +/// with one of these bytes". =0 / 2 paths +/// skip the DSCode entirely (region name, raw byte[], EventId, +/// flags, ...) — those are NOT DSCode-tagged. +/// +/// +/// We expose the values as const byte rather than a typed enum +/// because they almost always appear inline in a payload byte sequence: +/// WriteByte(DSCode.NullObj) reads more cleanly than +/// WriteByte((byte)DSCode.NullObj), and a switch on a byte read +/// from the wire works the same with constants as with an enum. +/// +/// +/// Phase 3 only uses , , +/// , , +/// , and . +/// The rest are filled in here as a one-time copy from cppcache so +/// later phases just reference them. +/// +/// +internal static class DSCode +{ + // Fixed-ID prefixes — used when serialising DataSerializableFixedId + // objects (e.g. EventId, ClientProxyMembershipId). + public const byte FixedIDDefault = 0; + public const byte FixedIDByte = 1; + public const byte FixedIDShort = 2; + public const byte FixedIDInt = 3; + public const byte FixedIDNone = 4; + + // User-data class IDs (DataSerializable). Phase 11+ for custom types. + public const byte CacheableUserData4 = 37; + public const byte CacheableUserData2 = 38; + public const byte CacheableUserData = 39; + + // Null sentinel — single-byte payload meaning "no value". + public const byte NullObj = 41; + + // Strings. + public const byte CacheableString = 42; // u16 byte-len + modified UTF-8 + public const byte CacheableNullString = 69; // bare DSCode (no body) + public const byte CacheableASCIIString = 87; // u16 char-len + ASCII bytes + public const byte CacheableASCIIStringHuge = 88; // i32 char-len + ASCII bytes + public const byte CacheableStringHuge = 89; // i32 char-len + UTF-16 BE + + // Class / Java serializable wrappers. + public const byte Class = 43; + public const byte JavaSerializable = 44; + public const byte DataSerializable = 45; + + // Byte arrays (special: usually IsObject=0 raw, not DSCode-tagged — + // see cppcache writeObjectPart line 676). + public const byte CacheableBytes = 46; + + // Numeric / boolean primitives (mostly Phase 4 territory). + public const byte CacheableBoolean = 53; + public const byte CacheableCharacter = 54; + public const byte CacheableByte = 55; + public const byte CacheableInt16 = 56; + public const byte CacheableInt32 = 57; + public const byte CacheableInt64 = 58; + public const byte CacheableFloat = 59; + public const byte CacheableDouble = 60; + public const byte CacheableDate = 61; + + // Primitive arrays. + public const byte BooleanArray = 26; + public const byte CharArray = 27; + public const byte CacheableInt16Array = 47; + public const byte CacheableInt32Array = 48; + public const byte CacheableInt64Array = 49; + public const byte CacheableFloatArray = 50; + public const byte CacheableDoubleArray = 51; + public const byte CacheableObjectArray = 52; + + // Collections (Phase 5+). + public const byte CacheableLinkedList = 10; + public const byte CacheableFileName = 63; + public const byte CacheableStringArray = 64; + public const byte CacheableArrayList = 65; + public const byte CacheableHashSet = 66; + public const byte CacheableHashMap = 67; + public const byte CacheableTimeUnit = 68; + public const byte CacheableHashTable = 70; + public const byte CacheableVector = 71; + public const byte CacheableIdentityHashMap = 72; + public const byte CacheableLinkedHashSet = 73; + public const byte CacheableStack = 74; + + // Misc infra. + public const byte Properties = 11; + public const byte PdxType = 17; + + // PDX (Phase 11). + public const byte PDX = 93; + public const byte PdxEnum = 94; +} diff --git a/src/Geode.Client/Protocol/DSFid.cs b/src/Geode.Client/Protocol/DSFid.cs new file mode 100644 index 0000000..28f2513 --- /dev/null +++ b/src/Geode.Client/Protocol/DSFid.cs @@ -0,0 +1,55 @@ +namespace Geode.Client.Protocol; + +/// +/// Fixed serialisation ids for built-in wire types — the +/// compId that follows / +/// in a serialised stream. +/// Mirrors cppcache enum class DSFid : int32_t +/// (cppcache/include/geode/internal/DSFixedId.hpp). +/// +/// +/// +/// Negative values are intentional — the wire protocol uses +/// signed-int regions to separate system-internal classes +/// (negative) from user-visible / GetAll-style classes (positive). +/// Keep backing storage so the round-trip with +/// cppcache int32_t stays bit-exact. +/// +/// +/// Each entry's wire role is documented inline. Not every entry +/// has a C# decoder yet — Phase 1.3 only consumes +/// (via the chunked-reply +/// path of RemoveAll / PutAll / GetAll70); +/// the rest are listed for cppcache parity and to avoid an +/// "incremental enum" pattern where every new decoder grows the +/// type. +/// +/// +internal enum DSFid : int +{ + GatewaySenderEventCallbackArgument = -135, + ClientHealthStats = -126, + VersionTag = -120, + CollectionTypeImpl = -59, + LocatorListRequest = -54, + ClientConnectionRequest = -53, + QueueConnectionRequest = -52, + LocatorListResponse = -51, + ClientConnectionResponse = -50, + QueueConnectionResponse = -49, + ClientReplacementRequest = -48, + GetAllServersRequest = -43, + GetAllServersResponse = -42, + VersionedObjectPartList = 7, + EnumInfo = 9, + CacheableObjectPartList = 25, + CacheableUndefined = 31, + Struct = 32, + EventId = 36, + InterestResultPolicy = 37, + ClientProxyMembershipId = 38, + InternalDistributedMember = 92, + TXCommitMessage = 110, + DiskVersionTag = 2131, + DiskStoreId = 2133, +} diff --git a/src/Geode.Client/Protocol/DataInput.cs b/src/Geode.Client/Protocol/DataInput.cs new file mode 100644 index 0000000..0a6b414 --- /dev/null +++ b/src/Geode.Client/Protocol/DataInput.cs @@ -0,0 +1,420 @@ +using System.Buffers.Binary; +using System.Text; + +namespace Geode.Client.Protocol; + +/// +/// Sequential big-endian reader over an in-memory buffer. +/// C# counterpart of cppcache DataInput / java.io.DataInput: +/// every multi-byte primitive is decoded from network byte order, matching +/// what a Geode server sends. +/// +/// +/// Not thread-safe. Single consumer, read-only. Throws +/// when a read would go past the end of +/// the buffer. +/// +/// BCL's System.IO.BinaryReader is little-endian, hence the explicit +/// "BigEndian" prefix on this type ??do not confuse the two. +/// +/// Methods marked "prototype" throw +/// and will be filled in as later phases need them. +/// +internal sealed class DataInput(ReadOnlyMemory buffer) +{ + private int _position; + + /// Current byte offset within the buffer. + public int Position => _position; + + /// Total length of the underlying buffer. + public int Length => buffer.Length; + + /// Bytes left to read from the current . + public int Remaining => buffer.Length - _position; + + // ====================================================================== + // Implemented (Phase 1 ??frame codec) + // ====================================================================== + + /// Read a single unsigned byte (u8). + public byte ReadByte() + { + EnsureAvailable(sizeof(byte)); + var value = buffer.Span[_position]; + _position += sizeof(byte); + return value; + } + + /// Read a single byte and interpret it as a boolean (0 = false, anything else = true). + public bool ReadBool() => ReadByte() != 0; + + /// Read a 32-bit signed integer in big-endian byte order. + public int ReadInt32() + { + EnsureAvailable(sizeof(int)); + var value = BinaryPrimitives.ReadInt32BigEndian(buffer.Span.Slice(_position, sizeof(int))); + _position += sizeof(int); + return value; + } + + /// Read a 64-bit signed integer in big-endian byte order. + public long ReadInt64() + { + EnsureAvailable(sizeof(long)); + var value = BinaryPrimitives.ReadInt64BigEndian(buffer.Span.Slice(_position, sizeof(long))); + _position += sizeof(long); + return value; + } + + /// + /// Read a raw byte sequence of the given length. Returns a zero-copy slice + /// of the underlying buffer; do not retain it past the buffer's lifetime. + /// Mirrors cppcache DataInput::readBytesOnly. + /// + public ReadOnlyMemory ReadBytesOnly(int count) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Length must be non-negative."); + EnsureAvailable(count); + var slice = buffer.Slice(_position, count); + _position += count; + return slice; + } + + // ====================================================================== + // Prototype ??additional primitives, fill in when first needed + // ====================================================================== + + /// Read a signed 8-bit integer (i8). + /// + /// Two's-complement reinterpretation of the next wire byte (e.g. 0xFF + /// ??-1), matching what Java's DataInput::readByte returns. + /// + public sbyte ReadSByte() => (sbyte)ReadByte(); + + /// Read a 16-bit signed integer in big-endian byte order. + public short ReadInt16() + { + EnsureAvailable(sizeof(short)); + var value = BinaryPrimitives.ReadInt16BigEndian(buffer.Span.Slice(_position, sizeof(short))); + _position += sizeof(short); + return value; + } + + /// Read a 16-bit unsigned integer in big-endian byte order. Mirrors cppcache readChar. + public ushort ReadUInt16() + { + EnsureAvailable(sizeof(ushort)); + var value = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span.Slice(_position, sizeof(ushort))); + _position += sizeof(ushort); + return value; + } + + /// Read a 32-bit unsigned integer in big-endian byte order. + public uint ReadUInt32() + { + EnsureAvailable(sizeof(uint)); + var value = BinaryPrimitives.ReadUInt32BigEndian(buffer.Span.Slice(_position, sizeof(uint))); + _position += sizeof(uint); + return value; + } + + /// Read a 64-bit unsigned integer in big-endian byte order. + public ulong ReadUInt64() + { + EnsureAvailable(sizeof(ulong)); + var value = BinaryPrimitives.ReadUInt64BigEndian(buffer.Span.Slice(_position, sizeof(ulong))); + _position += sizeof(ulong); + return value; + } + + /// + /// Read a Java-formatted string. Mirrors cppcache + /// DataInput::readString: 1-byte DSCode followed by + /// length-prefixed body. Returns for the + /// explicit sentinel. + /// + /// + /// Dispatched DSCodes: + /// + /// (69) ?? + /// (87) ??u16 length + ASCII bytes + /// (42) ??Java modified UTF-8 (see ) + /// (89) ??UTF-16 BE (Phase 4, currently NIE) + /// (88) ??Phase 4 NIE + /// + /// + public string? ReadString() + { + var dscode = ReadByte(); + return dscode switch + { + DSCode.CacheableNullString => null, + DSCode.CacheableASCIIString => ReadAsciiString(ReadUInt16()), + DSCode.CacheableString => ReadJavaModifiedUtf8(), + DSCode.CacheableASCIIStringHuge => throw new NotImplementedException( + "CacheableASCIIStringHuge (DSCode 88) ??Phase 4."), + DSCode.CacheableStringHuge => ReadUtf16Huge(), + _ => throw new GeodeException($"DataInput.ReadString: unexpected DSCode 0x{dscode:X2}."), + }; + } + + /// Length-prefixed-ASCII body reader shared by the u16 and i32 prefix variants. + private string ReadAsciiString(int length) + { + if (length < 0) + { + throw new GeodeException($"DataInput.ReadString: negative ASCII length {length}."); + } + if (length == 0) return string.Empty; + + EnsureAvailable(length); + var span = buffer.Span.Slice(_position, length); + _position += length; + return Encoding.ASCII.GetString(span); + } + + /// + /// Skip bytes forward. Mirrors cppcache + /// DataInput::advanceCursor. + /// + public void AdvanceCursor(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + EnsureAvailable(count); + _position += count; + } + + /// + /// Read a Java variable-length-encoded array length. Mirrors + /// cppcache DataInput::readArrayLength + /// (include/geode/DataInput.hpp:205-224): one byte for + /// lengths in [0, 252]; 0xFE + u16 for + /// [253, 65535]; 0xFD + i32 for larger; 0xFF + /// signals -1 (null array). + /// + /// + /// Leading byte is > 252 and not one of 0xFD / + /// 0xFE / 0xFF — corrupt stream. + /// + public int ReadArrayLength() + { + var code = ReadByte(); + if (code == 0xFF) + { + return -1; + } + if (code <= 252) + { + return code; + } + return code switch + { + 0xFE => ReadUInt16(), + 0xFD => ReadInt32(), + _ => throw new GeodeException( + $"DataInput.ReadArrayLength: unexpected length code 0x{code:X2}."), + }; + } + + /// + /// Read a Java variable-length-encoded unsigned long (1-9 bytes). + /// Mirrors cppcache DataInput::readUnsignedVL / + /// Java DataSerializer.readUnsignedVL: 7-bit-per-byte + /// little-endian with the top bit set on all bytes except the + /// last. + /// + /// + /// Algorithm: read bytes, accumulate (b & 0x7F) << shift; + /// stop when the top bit is clear. shift increments by 7 + /// per byte and caps at 64 bits — anything longer is + /// malformed. + /// + /// + /// More than 9 bytes consumed without seeing a terminator (the + /// VL encoding for a 64-bit value never exceeds 9 bytes). + /// + public ulong ReadUnsignedVL() + { + ulong result = 0; + var shift = 0; + while (shift < 64) + { + var b = ReadByte(); + result |= ((ulong)(b & 0x7F)) << shift; + if ((b & 0x80) == 0) + { + return result; + } + shift += 7; + } + throw new InvalidDataException( + "ReadUnsignedVL: malformed VL encoding (no terminator within 64 bits)."); + } + + /// Read an IEEE 754 single-precision float in big-endian byte order. + public float ReadFloat() + { + EnsureAvailable(sizeof(float)); + var value = BinaryPrimitives.ReadSingleBigEndian(buffer.Span.Slice(_position, sizeof(float))); + _position += sizeof(float); + return value; + } + + /// Read an IEEE 754 double-precision float in big-endian byte order. + public double ReadDouble() + { + EnsureAvailable(sizeof(double)); + var value = BinaryPrimitives.ReadDoubleBigEndian(buffer.Span.Slice(_position, sizeof(double))); + _position += sizeof(double); + return value; + } + + /// + /// Read a length-prefixed byte sequence: + /// length (varint) followed by the bytes, or null if the + /// sentinel is -1. Inverse of + /// ; mirrors cppcache + /// DataInput::readBytes. + /// + public byte[]? ReadBytes() + { + var length = ReadArrayLen(); + if (length == -1) return null; + return ReadBytesOnly(length).ToArray(); + } + + /// + /// Read Geode's variable-length array length encoding (1, 3, or 5 + /// bytes). Inverse of ; + /// mirrors cppcache DataInput::readArrayLen. + /// + /// + /// + /// First byte = 0xFF ??returns -1 (null sentinel). + /// First byte = 0xFE ??next u16 BE is the length. + /// First byte = 0xFD ??next i32 BE is the length. + /// First byte ??252 (0xFC) ??that byte is the length. + /// + /// The first byte is read as unsigned (matching + /// 's + /// WriteByte((byte)length) on the inline path) ??reading it + /// signed misinterprets lengths 128..252 as negative numbers. + /// + public int ReadArrayLen() + { + var first = ReadByte(); + return first switch + { + 0xFF => -1, // null sentinel + 0xFE => ReadUInt16(), // u16 follows + 0xFD => ReadInt32(), // i32 follows + _ => first, // 0..252 ??literal length + }; + } + + /// + /// Read a Java modified UTF-8 string with a u16 byte-length prefix. + /// Mirrors cppcache DataInput::readJavaModifiedUtf8. + /// + /// + /// + /// Modified UTF-8 differs from standard UTF-8: 0xC0 0x80 decodes + /// to \0, and supplementary codepoints arrive as a surrogate pair + /// of two 3-byte sequences (6 bytes total) rather than the 4-byte UTF-8 + /// form. We decode per UTF-16 code unit (matching how the writer + /// encoded) ??unpaired surrogates round-trip intact. + /// + /// + /// Empty payload (u16 length = 0) returns , + /// not null. Null strings travel as a separate DSCode + /// ( or + /// ) handled by the registry, not here. + /// + /// + /// + /// The byte sequence is not valid modified UTF-8 (lead byte outside + /// known ranges, or a continuation byte missing its 0x80..0xBF + /// mask). + /// + public string ReadJavaModifiedUtf8() + { + var byteLen = ReadUInt16(); + if (byteLen == 0) + { + return string.Empty; + } + + EnsureAvailable(byteLen); + var span = buffer.Span.Slice(_position, byteLen); + _position += byteLen; + + // Char-count upper bound = byte-count (1-byte chars max it out); + // typical strings allocate less. + var chars = new char[byteLen]; + var charPos = 0; + var bytePos = 0; + + while (bytePos < byteLen) + { + var b1 = span[bytePos++]; + if ((b1 & 0x80) == 0) + { + // 0xxxxxxx ??1-byte ASCII char (excludes 0x00 in modified UTF-8). + chars[charPos++] = (char)b1; + } + else if ((b1 & 0xE0) == 0xC0) + { + // 110xxxxx 10xxxxxx ??2-byte char (covers 0x0000??x07FF + // including the special 0xC0 0x80 = \0 encoding). + if (bytePos >= byteLen) throw MalformedUtf8(bytePos); + var b2 = span[bytePos++]; + if ((b2 & 0xC0) != 0x80) throw MalformedUtf8(bytePos - 1); + chars[charPos++] = (char)(((b1 & 0x1F) << 6) | (b2 & 0x3F)); + } + else if ((b1 & 0xF0) == 0xE0) + { + // 1110xxxx 10xxxxxx 10xxxxxx ??3-byte char (covers + // 0x0800??xFFFF and surrogate halves). + if (bytePos + 1 >= byteLen) throw MalformedUtf8(bytePos); + var b2 = span[bytePos++]; + var b3 = span[bytePos++]; + if ((b2 & 0xC0) != 0x80 || (b3 & 0xC0) != 0x80) + throw MalformedUtf8(bytePos - 2); + chars[charPos++] = (char)(((b1 & 0x0F) << 12) + | ((b2 & 0x3F) << 6) + | (b3 & 0x3F)); + } + else + { + throw MalformedUtf8(bytePos - 1); + } + } + + return new string(chars, 0, charPos); + + static FormatException MalformedUtf8(int byteOffset) => + new($"Malformed Java modified UTF-8 byte sequence at offset {byteOffset}."); + } + + /// + /// Read a UTF-16 big-endian string with an i32 byte-length prefix. + /// Mirrors cppcache DataInput::readUtf16Huge. + /// + public string? ReadUtf16Huge() => + throw new NotImplementedException("Phase 4 large string values."); + + // ====================================================================== + // Internal helpers + // ====================================================================== + + private void EnsureAvailable(int needed) + { + if (_position + needed > buffer.Length) + { + throw new EndOfStreamException( + $"Tried to read {needed} byte(s) at position {_position}, but only {buffer.Length - _position} remain."); + } + } +} diff --git a/src/Geode.Client/Protocol/DataOutput.cs b/src/Geode.Client/Protocol/DataOutput.cs new file mode 100644 index 0000000..78ce1a0 --- /dev/null +++ b/src/Geode.Client/Protocol/DataOutput.cs @@ -0,0 +1,336 @@ +using System.Buffers; +using System.Buffers.Binary; +using Geode.Client.Protocol; + +internal sealed class DataOutput + : IDisposable, IBufferWriter +{ + + private const int InitialSize = 8192; + + private byte[] _bytes = ArrayPool.Shared.Rent(InitialSize); + private int _disposed; + private int _position; + private int _writtenCount; + + private void EnsureCapacity(int additionalBytes) + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + var required = _position + additionalBytes; + if (required <= _bytes.Length) return; + + // Double until enough, mirroring cppcache ensureCapacity growth + // (it doubles too). ArrayPool gives at least requested; usually more. + var newSize = _bytes.Length; + while (newSize < required) newSize *= 2; + var newBytes = ArrayPool.Shared.Rent(newSize); + Array.Copy(_bytes, newBytes, _writtenCount); + ArrayPool.Shared.Return(_bytes); + _bytes = newBytes; + } + + public void Advance(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (_position + count > _bytes.Length) + { + throw new InvalidOperationException( + $"Advance({count}) past buffer end (pos={_position}, size={_bytes.Length})."); + } + _position += count; + if (_position > _writtenCount) _writtenCount = _position; + } + + /// + /// Advance the cursor by bytes, growing the + /// buffer if needed. Mirrors cppcache DataOutput::advanceCursor. + /// + public void AdvanceCursor(int n) + { + EnsureCapacity(n); + _position += n; + if (_position > _writtenCount) _writtenCount = _position; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + ArrayPool.Shared.Return(_bytes); + _bytes = []; + } + + public Memory GetMemory(int sizeHint = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(sizeHint); + EnsureCapacity(sizeHint > 0 ? sizeHint : 1); + return _bytes.AsMemory(_position); + } + + public Span GetSpan(int sizeHint = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(sizeHint); + EnsureCapacity(sizeHint > 0 ? sizeHint : 1); + return _bytes.AsSpan(_position); + } + + /// + /// Patch a 4-byte BE int at without + /// moving the current cursor. Convenience for placeholder back-fill + /// (mirrors cppcache updateValueAtPos for the 4-byte case). + /// + public void PatchInt32(int position, int value) + { + if (position < 0 || position + sizeof(int) > _writtenCount) + { + throw new ArgumentOutOfRangeException(nameof(position), + $"Patch range [{position}..{position + sizeof(int)}] " + + $"exceeds written count {_writtenCount}."); + } + BinaryPrimitives.WriteInt32BigEndian(_bytes.AsSpan(position), value); + } + + /// + /// Rewind the cursor by bytes (does not shrink + /// the high-water mark). Mirrors cppcache DataOutput::rewindCursor. + /// + public void RewindCursor(int n) + { + if (n < 0 || n > _position) + { + throw new ArgumentOutOfRangeException(nameof(n), + $"Cannot rewind {n} from position {_position}."); + } + _position -= n; + } + + /// + /// Copy of the written bytes ??caller-owned. + /// + public byte[] ToArray() => _bytes.AsSpan(0, _writtenCount).ToArray(); + + /// + /// Geode array-length encoding (1 / 3 / 5 bytes). Mirrors cppcache + /// DataOutput::writeArrayLen. + /// + public void WriteArrayLen(int length) + { + if (length == -1) + { + WriteSByte(-1); + } + else if (length <= 252) + { + WriteByte((byte)length); + } + else if (length <= 0xFFFF) { WriteSByte(-2); WriteUInt16((ushort)length); } + else { WriteSByte(-3); WriteInt32(length); } + } + + public void WriteBool(bool value) => WriteByte(value ? (byte)1 : (byte)0); + + public void WriteByte(byte value) + { + EnsureCapacity(1); + _bytes[_position++] = value; + if (_position > _writtenCount) _writtenCount = _position; + } + + /// + /// Length-prefixed byte sequence ( + + /// payload, or null sentinel). Mirrors cppcache writeBytes. + /// + public void WriteBytes(byte[]? bytes) + { + if (bytes is null) { WriteArrayLen(-1); return; } + WriteArrayLen(bytes.Length); + WriteBytesOnly(bytes); + } + + /// + /// Write a raw byte sequence verbatim. Mirrors cppcache DataOutput::writeBytesOnly. + /// + public void WriteBytesOnly(ReadOnlySpan bytes) + { + if (bytes.IsEmpty) return; + EnsureCapacity(bytes.Length); + bytes.CopyTo(_bytes.AsSpan(_position)); + _position += bytes.Length; + if (_position > _writtenCount) _writtenCount = _position; + } + + public void WriteDouble(double value) + { + EnsureCapacity(sizeof(double)); + BinaryPrimitives.WriteDoubleBigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(double); + if (_position > _writtenCount) _writtenCount = _position; + } + + public void WriteFloat(float value) + { + EnsureCapacity(sizeof(float)); + BinaryPrimitives.WriteSingleBigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(float); + if (_position > _writtenCount) _writtenCount = _position; + } + + public void WriteInt16(short value) + { + EnsureCapacity(sizeof(short)); + BinaryPrimitives.WriteInt16BigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(short); + if (_position > _writtenCount) _writtenCount = _position; + } + public void WriteInt32(int value) + { + EnsureCapacity(sizeof(int)); + BinaryPrimitives.WriteInt32BigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(int); + if (_position > _writtenCount) _writtenCount = _position; + } + + + + public void WriteInt64(long value) + { + EnsureCapacity(sizeof(long)); + BinaryPrimitives.WriteInt64BigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(long); + if (_position > _writtenCount) _writtenCount = _position; + } + + /// + /// Java modified UTF-8 with u16 byte-length prefix. Mirrors cppcache writeJavaModifiedUtf8. + /// + public void WriteJavaModifiedUtf8(string? value) + { + var s = value ?? string.Empty; + + var byteLen = 0; + foreach (var c in s) + { + if (c >= 0x0001 && c <= 0x007F) byteLen += 1; + else if (c == 0 || (c >= 0x0080 && c <= 0x07FF)) byteLen += 2; + else byteLen += 3; + } + + if (byteLen > 0xFFFF) + { + throw new FormatException( + $"String too long for Java modified UTF-8: {byteLen} bytes (max 65535)."); + } + + WriteUInt16((ushort)byteLen); + if (byteLen == 0) return; + + EnsureCapacity(byteLen); + var body = _bytes.AsSpan(_position, byteLen); + var pos = 0; + foreach (var c in s) + { + if (c >= 0x0001 && c <= 0x007F) + { + body[pos++] = (byte)c; + } + else if (c == 0 || (c >= 0x0080 && c <= 0x07FF)) + { + body[pos++] = (byte)(0xC0 | (c >> 6)); + body[pos++] = (byte)(0x80 | (c & 0x3F)); + } + else + { + body[pos++] = (byte)(0xE0 | (c >> 12)); + body[pos++] = (byte)(0x80 | ((c >> 6) & 0x3F)); + body[pos++] = (byte)(0x80 | (c & 0x3F)); + } + } + _position += byteLen; + if (_position > _writtenCount) _writtenCount = _position; + } + + public void WriteSByte(sbyte value) => WriteByte((byte)value); + + public void WriteString(string? value) + { + if (value is null) { WriteByte(DSCode.CacheableNullString); return; } + + var hasNonAscii = false; + foreach (var c in value) + { + if (c == 0 || c > 0x007F) { hasNonAscii = true; break; } + } + + if (hasNonAscii) + { + WriteByte(DSCode.CacheableString); + WriteJavaModifiedUtf8(value); + return; + } + + if (value.Length > 0xFFFF) + { + throw new NotImplementedException( + $"CacheableASCIIStringHuge encoding (length {value.Length} > 65535) " + + "is not implemented; add when a real wire field needs it."); + } + + WriteByte(DSCode.CacheableASCIIString); + WriteUInt16((ushort)value.Length); + EnsureCapacity(value.Length); + for (var i = 0; i < value.Length; i++) _bytes[_position++] = (byte)value[i]; + if (_position > _writtenCount) _writtenCount = _position; + } + + /// + /// Mirrors cppcache writeChar (Java char = u16). + /// + public void WriteUInt16(ushort value) + { + EnsureCapacity(sizeof(ushort)); + BinaryPrimitives.WriteUInt16BigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(ushort); + if (_position > _writtenCount) _writtenCount = _position; + } + + public void WriteUInt32(uint value) + { + EnsureCapacity(sizeof(uint)); + BinaryPrimitives.WriteUInt32BigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(uint); + if (_position > _writtenCount) _writtenCount = _position; + } + + public void WriteUInt64(ulong value) + { + EnsureCapacity(sizeof(ulong)); + BinaryPrimitives.WriteUInt64BigEndian(_bytes.AsSpan(_position), value); + _position += sizeof(ulong); + if (_position > _writtenCount) _writtenCount = _position; + } + + public int Position + { + get => _position; + set + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + if (value < 0 || value > _writtenCount) + { + throw new ArgumentOutOfRangeException(nameof(value), + $"Position {value} out of range [0, {_writtenCount}]."); + } + _position = value; + } + } + + /// + /// Total bytes written so far (high-water mark, not current cursor). + /// + public int WrittenCount => _writtenCount; + + /// + /// Bytes written so far (up to the high-water mark). + /// + public ReadOnlySpan WrittenSpan => _bytes.AsSpan(0, _writtenCount); + +} diff --git a/src/Geode.Client/Protocol/DiskVersionTag.cs b/src/Geode.Client/Protocol/DiskVersionTag.cs new file mode 100644 index 0000000..56d0ad7 --- /dev/null +++ b/src/Geode.Client/Protocol/DiskVersionTag.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Protocol; + +/// +/// Variant of for persistent regions — +/// reads member ids as DiskStoreId rather than +/// InternalDistributedMember. Mirrors cppcache +/// DiskVersionTag +/// (cppcache/src/DiskVersionTag.hpp). +/// +/// +/// +/// Phase 1.3.b status: empty shell. Only difference from +/// is the override +/// (uses DiskStoreId.FromData instead of +/// InternalDistributedMember.FromData) and the +/// DSFid value ( = 2131 +/// vs = -120). +/// +/// +/// Phase 1.3 RemoveAll doesn't target persistent regions, so this +/// class is unreachable today — +/// step 6 +/// always constructs a plain . The +/// dispatch (and a DiskStoreId wire decoder) lands when +/// persistent regions become a target. +/// +/// +/// Logger category mismatch tolerated. Ctor takes +/// typed against +/// to satisfy the base ctor signature; logs from a +/// DiskVersionTag would appear under the +/// VersionTag category. Acceptable for an internal class. +/// +/// +internal sealed class DiskVersionTag( + IServiceProvider serviceProvider, + ILogger logger, + MemberListForVersionStamp? memberListForVersionStamp = null) + : VersionTag(serviceProvider, logger, memberListForVersionStamp) +{ + /// + /// + /// cppcache reads DiskStoreId instances and routes them + /// through the same member-list registry as + /// ; the result is the same + /// m_internalMemId / m_previousMemId ushort slots + /// pointing into the registry. + /// + protected override void ReadMembers(ushort flags, DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + _ = flags; + throw new NotImplementedException( + "DiskVersionTag.ReadMembers pending Phase 4+ (persistent regions)."); + } +} + diff --git a/src/Geode.Client/Protocol/MemberListForVersionStamp.cs b/src/Geode.Client/Protocol/MemberListForVersionStamp.cs new file mode 100644 index 0000000..f646666 --- /dev/null +++ b/src/Geode.Client/Protocol/MemberListForVersionStamp.cs @@ -0,0 +1,96 @@ +namespace Geode.Client.Protocol; + +/// +/// Per-cluster registry mapping member-id objects +/// (ClientProxyMembershipID / DiskStoreId) to compact +/// ids used in version-stamp wire encoding. +/// Mirrors cppcache MemberListForVersionStamp +/// (cppcache/src/MemberListForVersionStamp.hpp). +/// +/// +/// +/// Phase 1.3.b status: empty shell. Holds the field shape +/// cppcache uses (two parallel dicts + monotonic counter) and the +/// two public methods ( / ) +/// as NIE stubs. Real body lands when +/// reads +/// ClientProxyMembershipID.ReadEssentialData bytes and feeds +/// them through here (Phase 4+). +/// +/// +/// Member type placeholder. cppcache stores +/// shared_ptr<DSMemberForVersionStamp>; we use +/// ? until the type lands. Concrete subtypes +/// will be the C# equivalents of cppcache's +/// ClientProxyMembershipID and DiskStoreId. +/// +/// +// _memberCounter stays placeholder until Add body lands. +#pragma warning disable CS0649 +internal sealed class MemberListForVersionStamp +{ + /// cppcache m_members1: numeric id → member. + private readonly Dictionary _members1 = new(); + + /// cppcache m_members2: string-key → (member, id). + private readonly Dictionary _members2 = new(); + + /// cppcache m_memberCounter: monotonic id allocator. + private uint _memberCounter; + + /// cppcache mutex_ (boost::shared_mutex). We use a + /// plain lock until contention shows we need + /// a reader-writer lock. + private readonly object _lock = new(); + + /// + /// Register a member-id object, return the compact ushort id. + /// Mirrors cppcache add(member) + /// (cppcache/src/MemberListForVersionStamp.cpp:36-50). + /// + /// + /// Phase 1.3 — hashKey dedup deferred. cppcache looks up + /// member.getHashKey() in and + /// returns the previously-allocated id if the same member was + /// registered before. Phase 1.3 always allocates a fresh + /// monotonic id — wasteful when the same member appears across + /// chunks but unobservable in the single-chunk RemoveAll decode + /// path. Phase 4+ adds the dedup once + /// ClientProxyMembershipID.HashKey exists. + /// + public ushort Add(object? member) + { + ArgumentNullException.ThrowIfNull(member); + lock (_lock) + { + // TODO Phase 4+ — hashKey-based dedup; see remarks. + _memberCounter++; + var id = (ushort)_memberCounter; + _members1[_memberCounter] = member; + _ = _members2; // reserved for hashKey dedup (Phase 4+) + return id; + } + } + + /// + /// Look up a member-id object by its compact ushort id. Mirrors + /// cppcache getDSMember(memberId) + /// (cppcache/src/MemberListForVersionStamp.cpp:53-61). + /// + public object? GetDsMember(ushort memberId) + { + lock (_lock) + { + return _members1.TryGetValue(memberId, out var member) ? member : null; + } + } +} + +/// +/// Member + compact-id pair stored in +/// 's string-keyed dict. +/// Mirrors cppcache DistributedMemberWithIntIdentifier. +/// +internal sealed record DistributedMemberWithIntIdentifier( + object? Member, + ushort Identifier); diff --git a/src/Geode.Client/Protocol/MessageType.cs b/src/Geode.Client/Protocol/MessageType.cs new file mode 100644 index 0000000..967052f --- /dev/null +++ b/src/Geode.Client/Protocol/MessageType.cs @@ -0,0 +1,147 @@ +namespace Geode.Client.Protocol; + +/// +/// TCR message-type identifier (i32 big-endian on the wire). +/// Mirrors enum MsgType in +/// cppcache/src/TcrMessage.hpp (apache/geode-native). +/// +/// +/// Negative-valued entries (, +/// ) are sentinels used by the C++ +/// client internally and never appear on the wire. We keep them so the +/// numeric-to-name mapping is exhaustive when debugging. +/// Numeric gaps (57, 95, 101, 102, 104) are preserved as-is from the +/// upstream enum. +/// +internal enum MessageType +{ + // --- sentinels (not on the wire) --- + NotPublicApiWithTimeout = -2, + Invalid = -1, + + // --- core CRUD + lifecycle --- + Request = 0, // GET + Response = 1, // reply to Request + Exception = 2, // server-side error + RequestDataError = 3, + DataNotFoundError = 4, // not in use + Ping = 5, + Reply = 6, // generic ack + Put = 7, + PutDataError = 8, + Destroy = 9, // remove single key + DestroyDataError = 10, + DestroyRegion = 11, + DestroyRegionDataError = 12, + ClientNotification = 13, + UpdateClientNotification = 14, + LocalInvalidate = 15, + LocalDestroy = 16, + LocalDestroyRegion = 17, + CloseConnection = 18, // graceful disconnect + ProcessBatch = 19, + RegisterInterest = 20, + RegisterInterestDataError = 21, + UnregisterInterest = 22, + UnregisterInterestDataError = 23, + RegisterInterestList = 24, + UnregisterInterestList = 25, + UnknownMessageTypeError = 26, + LocalCreate = 27, + LocalUpdate = 28, + CreateRegion = 29, + CreateRegionDataError = 30, + MakePrimary = 31, + ResponseFromPrimary = 32, + ResponseFromSecondary = 33, + Query = 34, // OQL + QueryDataError = 35, + ClearRegion = 36, + ClearRegionDataError = 37, + ContainsKey = 38, + ContainsKeyDataError = 39, + KeySet = 40, + KeySetDataError = 41, + + // --- continuous queries (CQ) --- + ExecuteCq = 42, + ExecuteCqWithIr = 43, + StopCq = 44, + CloseCq = 45, + CloseClientCqs = 46, + CqDataError = 47, + GetCqStats = 48, + MonitorCq = 49, + CqException = 50, + + // --- registration / lifecycle (continued) --- + RegisterInstantiators = 51, + PeriodicAck = 52, + ClientReady = 53, + ClientMarker = 54, + InvalidateRegion = 55, + PutAll = 56, // bulk PUT + // 57 — not assigned upstream + GetAllDataError = 58, + + // --- function execution --- + ExecuteRegionFunction = 59, + ExecuteRegionFunctionResult = 60, + ExecuteRegionFunctionError = 61, + ExecuteFunction = 62, + ExecuteFunctionResult = 63, + ExecuteFunctionError = 64, + + // --- client interest / metadata --- + ClientRegisterInterest = 65, + ClientUnregisterInterest = 66, + RegisterDataSerializers = 67, + RequestEventValue = 68, + RequestEventValueError = 69, + PutDeltaError = 70, + GetClientPrMetadata = 71, + ResponseClientPrMetadata = 72, + GetClientPartitionAttributes = 73, + ResponseClientPartitionAttributes = 74, + GetClientPrMetadataError = 75, + GetClientPartitionAttributesError = 76, + + // --- auth --- + UserCredentialMessage = 77, + RemoveUserAuth = 78, + + ExecuteRegionFunctionSingleHop = 79, + QueryWithParameters = 80, + Size = 81, + SizeError = 82, + Invalidate = 83, + InvalidateError = 84, + + // --- transactions --- + Commit = 85, + CommitError = 86, + Rollback = 87, + TxFailover = 88, + GetEntry = 89, + TxSynchronization = 90, + GetFunctionAttributes = 91, + + // --- PDX --- + GetPdxTypeById = 92, + GetPdxIdForType = 93, + AddPdxType = 94, + // 95 — not assigned upstream + AddPdxEnum = 96, + GetPdxIdForEnum = 97, + GetPdxEnumById = 98, + + ServerToClientPing = 99, // server-initiated keepalive + GetAll70 = 100, // bulk GET (Geode 7.0+ wire) + // 101, 102, 104 — not assigned upstream + TombstoneOperation = 103, + GetDurableCqs = 105, + GetDurableCqsDataError = 106, + GetAllWithCallback = 107, + PutAllWithCallback = 108, + RemoveAll = 109, +} diff --git a/src/Geode.Client/Protocol/ProtocolVersion.cs b/src/Geode.Client/Protocol/ProtocolVersion.cs new file mode 100644 index 0000000..13523de --- /dev/null +++ b/src/Geode.Client/Protocol/ProtocolVersion.cs @@ -0,0 +1,71 @@ +namespace Geode.Client.Protocol; + +/// +/// Geode wire-protocol version ordinal. Mirrors +/// cppcache/src/Version.hpp and Version.cpp. +/// +/// +/// +/// Only the ordinal goes on the wire ??major/minor/patch are not part of +/// the handshake (despite what some upstream comments imply). Two encodings: +/// +/// +/// +/// Compressed (default, ordinal ??): +/// 1 byte (i8) carrying the ordinal directly. +/// +/// +/// Uncompressed (ordinal > 127): sentinel byte -1 +/// followed by i16 ordinal (3 bytes total). +/// +/// +/// +/// The uncompressed branch is unreachable today ( = 125) +/// but kept in so we don't get caught out when +/// upstream eventually crosses 127. +/// +/// +internal readonly record struct ProtocolVersion(short Ordinal) +{ + /// + /// The ordinal this client identifies itself as on every handshake. + /// cppcache Version::current() hardcodes 125 (= "Geode 1.14.0" + /// wire protocol); any 1.14+ server is backward-compatible. + /// + /// + /// Bump this only when: + /// + /// We need a feature gated behind a newer ordinal. + /// The new ordinal > 127 ??at which point + /// starts taking the uncompressed branch; verify it's correct. + /// + /// + public static ProtocolVersion Current => new(125); + + /// + /// Sentinel byte that signals "uncompressed encoding follows" in the + /// cppcache wire format. Defined as kTokenOrdinal there. + /// + private const sbyte TokenOrdinal = -1; + + /// + /// Append this version to using the cppcache + /// Version::write wire format. + /// + public void WriteTo(DataOutput writer) + { + if (Ordinal <= sbyte.MaxValue) + { + // Compressed form: ordinal fits in i8, write it directly. + writer.WriteSByte((sbyte)Ordinal); + } + else + { + // Uncompressed form: sentinel byte tells the peer "an i16 + // ordinal follows". Unreachable today (Current = 125), kept + // honest so a future bump past 127 just works. + writer.WriteSByte(TokenOrdinal); + writer.WriteInt16(Ordinal); + } + } +} diff --git a/src/Geode.Client/Protocol/Serialization/BooleanArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/BooleanArrayDataConverter.cs new file mode 100644 index 0000000..85cd841 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/BooleanArrayDataConverter.cs @@ -0,0 +1,92 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (26). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes; see +/// ) followed by +/// one byte per element (0 = false, 0x01 = true). +/// Mirrors cppcache BooleanArray +/// (cppcache/src/CacheableBuiltins.cpp typedef of +/// CacheableArrayPrimitive<bool, BooleanArray>) which +/// routes through serializer::writeArrayObject ??/// writeArrayLen(size) + per-element writeObject(bool). +/// +/// +/// +/// Distinct from [] (DSCode 46) on the wire: +/// same length prefix + 1-byte-per-element shape, but the server +/// materialises this as Java boolean[] rather than +/// byte[]. Encoder bug that wrote into the wrong DSCode would +/// be silently round-trip-equivalent on the client and only surface +/// when a Java consumer reads it. +/// +/// +/// Not a Key. Same reasoning as +/// ?? doesn't implement +/// , so 's +/// where TKey : IEquatable<TKey> constraint rejects +/// [] keys at compile time. Values are fine. +/// +/// +/// null is intercepted by +/// ahead of this +/// converter and emitted as ; an empty +/// array writes [26, 0x00] (DSCode + length 0) and reads back +/// as . +/// +/// +internal sealed class BooleanArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.BooleanArray }; + + /// + /// Snapshot of SystemProperties.MaxArrayLength at construction. + /// The cache's properties bag is fixed for its lifetime, so caching + /// the value avoids a property-chain walk on every wire op. + /// + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, bool[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"BooleanArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteBool(element); + } + return ValueTask.CompletedTask; + } + + public override bool[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"BooleanArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate. " + + "Treat as a hostile / buggy payload unless a legitimate workload " + + "warrants raising the limit."); + } + var array = new bool[length]; + for (var i = 0; i < length; i++) + { + array[i] = reader.ReadBool(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/BooleanDataConverter.cs b/src/Geode.Client/Protocol/Serialization/BooleanDataConverter.cs new file mode 100644 index 0000000..398613f --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/BooleanDataConverter.cs @@ -0,0 +1,23 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (53). Wire payload is 1 +/// byte: 0 = false, non-zero = true. Mirrors cppcache +/// CacheableBoolean (cppcache/src/CacheableBuiltins.cpp +/// toData / fromData). +/// +internal sealed class BooleanDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableBoolean }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, bool value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteByte(value ? (byte)1 : (byte)0); + return ValueTask.CompletedTask; + } + + public override bool Read(DataInput reader, byte dsCode, int depth) => + reader.ReadByte() != 0; +} diff --git a/src/Geode.Client/Protocol/Serialization/ByteDataConverter.cs b/src/Geode.Client/Protocol/Serialization/ByteDataConverter.cs new file mode 100644 index 0000000..11270bc --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/ByteDataConverter.cs @@ -0,0 +1,34 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (55). Wire payload is 1 byte. +/// Mirrors cppcache CacheableByte +/// (cppcache/src/CacheableBuiltins.cpp toData / +/// fromData). +/// +/// +/// Signed vs unsigned: cppcache / Java treat +/// CacheableByte as int8_t / signed Java byte +/// (range -128..127). We expose it as .NET +/// (unsigned 0..255) ??the wire bit pattern is identical +/// (.NET 255 ??Java -1 ??wire 0xFF) so +/// interop is correct; only the cross-language debug display +/// differs. Choosing byte over matches +/// .NET convention and keeps the type symmetrical with +/// ([]). +/// +internal sealed class ByteDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableByte }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, byte value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteByte(value); + return ValueTask.CompletedTask; + } + + public override byte Read(DataInput reader, byte dsCode, int depth) => + reader.ReadByte(); +} diff --git a/src/Geode.Client/Protocol/Serialization/BytesDataConverter.cs b/src/Geode.Client/Protocol/Serialization/BytesDataConverter.cs new file mode 100644 index 0000000..f17e5ee --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/BytesDataConverter.cs @@ -0,0 +1,82 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (46). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes, see +/// ) followed by +/// the raw bytes. Mirrors cppcache CacheableBytes +/// (cppcache/include/geode/internal/CacheableBuiltinTemplates.hpp +/// CacheableArrayPrimitive<int8_t, CacheableBytes>) which +/// routes through serializer::writeArrayObject ??/// writeArrayLen(size) + per-byte writeObject(int8_t). +/// +/// +/// +/// Not a Key. cppcache's CacheableArrayPrimitive derives +/// from DataSerializablePrimitive only, NOT +/// CacheableKey ??Java Arrays.equals / Arrays.hashCode +/// are array-content semantics that don't match the per-class +/// operator== / hashcode() contract CacheableKey +/// requires. .NET enforces the same exclusion at compile time: the +/// where TKey : IEquatable<TKey> constraint on +/// rejects [] +/// because does not implement +/// . So [] +/// values work; [] keys don't compile. +/// +/// +/// null vs (): +/// +/// +/// +/// null is intercepted by +/// ahead of the +/// converter and written as (41). +/// This converter never sees a null write input. +/// +/// +/// () writes +/// [46, 0x00] ??DSCode + VL-encoded length 0, no payload. +/// Read returns a (possibly fresh) zero-length array, not null. +/// +/// +/// +internal sealed class BytesDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableBytes }; + + private readonly int _maxBytesLength + = cache.CacheProperties.MaxBytesLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, byte[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxBytesLength) + { + throw new InvalidOperationException( + $"BytesDataConverter: cannot serialise a byte[] of {value.Length} bytes " + + $"— exceeds Serialization.MaxBytesLength ({_maxBytesLength})."); + } + writer.WriteBytes(value); + return ValueTask.CompletedTask; + } + + public override byte[]? Read(DataInput reader, byte dsCode, int depth) + { + // Inline the length read so we can bounds-check before + // allocating. reader.ReadBytes() does the same two steps + // internally; we just split them to insert the limit gate. + var length = reader.ReadArrayLen(); + if (length == -1) return null; + if (length > _maxBytesLength) + { + throw new GeodeException( + $"BytesDataConverter: wire byte[] length {length} exceeds " + + $"Serialization.MaxBytesLength ({_maxBytesLength}) ??refusing to allocate."); + } + return reader.ReadBytesOnly(length).ToArray(); + } +} diff --git a/src/Geode.Client/Protocol/Serialization/CharArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/CharArrayDataConverter.cs new file mode 100644 index 0000000..f664edd --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/CharArrayDataConverter.cs @@ -0,0 +1,70 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (27). Wire payload is a VL-encoded +/// length (1 / 3 / 5 bytes) followed by 2 bytes big-endian per +/// element ??each element is one Java char / UTF-16 code +/// unit. Mirrors cppcache CharArray +/// (CacheableArrayPrimitive<char16_t, CharArray>). +/// +/// +/// +/// Same per-element wire shape as +/// (DSCode 54): one big-endian u16. The array form just precedes the +/// element stream with a VL length prefix. +/// +/// +/// Not a Key ??see . +/// null is intercepted as by the +/// registry; writes [27, 0x00]. +/// +/// +internal sealed class CharArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CharArray }; + + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, char[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"CharArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteUInt16(element); + } + return ValueTask.CompletedTask; + } + + public override char[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"CharArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate."); + } + var array = new char[length]; + for (var i = 0; i < length; i++) + { + array[i] = (char)reader.ReadUInt16(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/CharacterDataConverter.cs b/src/Geode.Client/Protocol/Serialization/CharacterDataConverter.cs new file mode 100644 index 0000000..f90d1ed --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/CharacterDataConverter.cs @@ -0,0 +1,31 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (54). Wire payload is 2 +/// bytes big-endian (UTF-16 code unit, 0..65535). Mirrors cppcache +/// CacheableCharacter +/// (cppcache/src/CacheableBuiltins.cpp toData / +/// fromData) which serialises char16_t as u16. +/// +/// +/// Java char is a UTF-16 code unit (unsigned 16-bit) and so is +/// .NET ??one-to-one mapping, no surrogate pairs +/// handled at this layer (a single char can be an unpaired surrogate +/// half; that's the caller's concern, the wire just carries the +/// code unit). +/// +internal sealed class CharacterDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableCharacter }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, char value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteUInt16(value); + return ValueTask.CompletedTask; + } + + public override char Read(DataInput reader, byte dsCode, int depth) => + (char)reader.ReadUInt16(); +} diff --git a/src/Geode.Client/Protocol/Serialization/DataConverter`1.cs b/src/Geode.Client/Protocol/Serialization/DataConverter`1.cs new file mode 100644 index 0000000..8d124d6 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/DataConverter`1.cs @@ -0,0 +1,54 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// Default base for built-in codecs. Bridges the typed +/// contract to the erased +/// one used by the registry, so concrete +/// codecs only override the typed methods. +/// +/// CLR type the codec serialises. +/// +/// Single-DSCode converters (the common case) only override +/// , , +/// and . They inherit +/// the default which returns +/// DsCodes[0] ??fine because their array +/// is one element long. Multi-DSCode converters (only +/// StringDataConverter today) override +/// to scan the value and branch. +/// +internal abstract class DataConverter : IDataConverter +{ + public abstract byte[] DsCodes { get; } + + public Type ManagedType => typeof(T); + + /// + /// Default: emit the first (and usually only) DSCode this + /// converter handles. Multi-DSCode converters override. + /// + public virtual byte GetDsCode(T value) => DsCodes[0]; + + public abstract ValueTask WriteAsync(DataOutput writer, T value, byte dsCode, int depth, CancellationToken ct); + + public abstract T? Read(DataInput reader, byte dsCode, int depth); + + /// 預設:跑 sync 包成 + public virtual ValueTask ReadAsync(DataInput reader, byte dsCode, int depth, CancellationToken ct) => + ValueTask.FromResult(Read(reader, dsCode, depth)); + + // Bridges to the non-generic interface — registry holds IDataConverter, + // dispatches via these overloads. Casts are safe because the registry + // looks codecs up by ManagedType / DsCodes. + byte IDataConverter.GetDsCode(object value) => + GetDsCode((T)value); + + object? IDataConverter.Read(DataInput reader, byte dsCode, int depth) => + Read(reader, dsCode, depth); + + ValueTask IDataConverter.WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct) => + WriteAsync(writer, (T)value, dsCode, depth, ct); + + async ValueTask IDataConverter.ReadAsync(DataInput reader, byte dsCode, int depth, CancellationToken ct) => + await ReadAsync(reader, dsCode, depth, ct); +} diff --git a/src/Geode.Client/Protocol/Serialization/DateTimeDataConverter.cs b/src/Geode.Client/Protocol/Serialization/DateTimeDataConverter.cs new file mode 100644 index 0000000..7c39e89 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/DateTimeDataConverter.cs @@ -0,0 +1,74 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (61). Wire payload is an 8-byte +/// big-endian signed integer: milliseconds since the Unix epoch +/// (1970-01-01T00:00:00Z), matching Java +/// java.util.Date.getTime(). Mirrors cppcache +/// CacheableDate (cppcache/src/CacheableDate.cpp). +/// +/// +/// +/// Round-trip is UTC-safe. always returns +/// a with = +/// . This deliberately diverges from +/// the C++/CLI clicache reference implementation +/// (geode-native/clicache/src/CacheableDate.cpp::FromData) +/// which calls ToLocalTime() on read ??that introduces a +/// subtle Kind-flip footgun where +/// DateTime.UtcNow ??wire ??Kind=Local. We keep the instant +/// stable in UTC; callers wanting local-time display call +/// explicitly. +/// +/// +/// Write rejects . +/// DateTime.ToUniversalTime silently assumes +/// Unspecified means Local ??which makes wire output depend on the +/// runtime's local timezone, a cross-host non-determinism we refuse +/// to inherit. cppcache / clicache don't model Kind at all so this +/// concern is .NET-only. Callers must set +/// explicitly via or the +/// +/// overload. +/// +/// +/// Precision is millisecond. Sub-millisecond ticks are +/// truncated on write (no rounding) ??matches the natural .NET +/// behaviour of +/// and avoids the clicache "round to nearest ms" quirk where +/// t.AddTicks(1) == t can become true. +/// +/// +internal sealed class DateTimeDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableDate }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, DateTime value, byte dsCode, int depth, CancellationToken ct) + { + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + DateTimeKind.Unspecified => throw new ArgumentException( + "DateTime with Kind=Unspecified cannot be serialised: " + + "the wire form is UTC milliseconds and Unspecified would " + + "force a silent local-timezone assumption. Use " + + "DateTime.SpecifyKind(value, DateTimeKind.Utc) or construct " + + "with an explicit Kind.", + nameof(value)), + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + long ms = (utc - DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond; + writer.WriteInt64(ms); + return ValueTask.CompletedTask; + } + + public override DateTime Read(DataInput reader, byte dsCode, int depth) + { + long ms = reader.ReadInt64(); + // DateTime.UnixEpoch is Kind=Utc; AddTicks preserves Kind. + return DateTime.UnixEpoch.AddTicks(ms * TimeSpan.TicksPerMillisecond); + } +} diff --git a/src/Geode.Client/Protocol/Serialization/DictionaryDataConverter.cs b/src/Geode.Client/Protocol/Serialization/DictionaryDataConverter.cs new file mode 100644 index 0000000..793dff6 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/DictionaryDataConverter.cs @@ -0,0 +1,127 @@ +using System.Collections; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for Dictionary<K,V> / +/// IDictionary<K,V> ??/// (67). Wire payload is a +/// VL-encoded entry count followed by N +/// (key, value) pairs ??each side a fully-serialised object +/// with its own DSCode. Mirrors cppcache CacheableHashMap + +/// the generic writeObject(unordered_map) in +/// cppcache/include/geode/Serializer.hpp:338-348. +/// +/// +/// +/// Open-generic registration. returns +/// typeof(Dictionary<,>); the registry's WriteObject +/// dispatch falls back to +/// when the closed-type lookup misses, so one instance covers every +/// closed Dictionary<K,V>. +/// +/// +/// Key-value interleaved on the wire. Entries are +/// [k0, v0, k1, v1, ?�] (cppcache calls writeObject(key) +/// then writeObject(value) per entry), NOT all-keys-then-all- +/// values. Read mirrors the order. Iteration order is non- +/// deterministic ??same as std::unordered_map. +/// +/// +/// Read returns canonical Dictionary<object, object?>. +/// Target-shape conversion (Dictionary<int, string>, +/// IDictionary<K,V>, ?? happens at +/// in +/// , not here. +/// +/// +/// Null keys are rejected on read. Java HashMap permits +/// one null key; Dictionary<object, object?> does not +/// (the underlying -keyed Dictionary still throws +/// ArgumentNullException on a null key). If the wire ever +/// carries a null key (a Java-side map.put(null, v)) we throw +/// with a descriptive message rather +/// than let Dictionary surface a generic argument-null error. +/// Null values are fine ??both sides allow that. +/// +/// +/// Registry back-reference. Same pattern as +/// / +/// ??each key + value re-enters +/// / +/// so nested maps / +/// lists / arbitrary registered types can occupy slots. +/// +/// +internal sealed class DictionaryDataConverter : IDataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableHashMap }; + + private readonly SerializationRegistry _registry; + + public DictionaryDataConverter(SerializationRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public byte[] DsCodes => _dsCodes; + + /// + /// Open-generic Dictionary<,>. Registry's write + /// dispatch reaches this converter via + /// when the closed- + /// type lookup misses. + /// + public Type ManagedType => typeof(Dictionary<,>); + + public byte GetDsCode(object value) => DSCode.CacheableHashMap; + + public async ValueTask WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct) + { + var source = (IDictionary)value; + if (source.Count > _registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"DictionaryDataConverter: cannot serialise a map of {source.Count} entries " + + $"— exceeds Serialization.MaxArrayLength ({_registry.MaxArrayLength})."); + } + writer.WriteArrayLen(source.Count); + foreach (DictionaryEntry entry in source) + { + await _registry.WriteObjectAsync(writer, entry.Key, depth + 1, ct); + await _registry.WriteObjectAsync(writer, entry.Value, depth + 1, ct); + } + } + + public object? Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return new Dictionary(); + } + if (length > _registry.MaxArrayLength) + { + throw new GeodeException( + $"DictionaryDataConverter: wire map length {length} exceeds " + + $"Serialization.MaxArrayLength ({_registry.MaxArrayLength}) ??refusing to allocate."); + } + + var dict = new Dictionary(capacity: length); + for (var i = 0; i < length; i++) + { + var key = _registry.ReadObject(reader, depth + 1); + var value = _registry.ReadObject(reader, depth + 1); + + if (key is null) + { + throw new GeodeException( + $"CacheableHashMap: wire entry #{i} has a null key; " + + "Java HashMap permits this but Dictionary does not."); + } + + dict.Add(key, value); + } + return dict; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/DoubleArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/DoubleArrayDataConverter.cs new file mode 100644 index 0000000..35747e5 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/DoubleArrayDataConverter.cs @@ -0,0 +1,63 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (51). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by 8 bytes big-endian +/// IEEE-754 per element. Mirrors cppcache CacheableDoubleArray +/// (CacheableArrayPrimitive<double, CacheableDoubleArray>). +/// +/// +/// Per-element wire shape matches +/// (DSCode 60) ??NaN / ±Infinity round-trip preserves IEEE-754 bit +/// pattern. Same key / null / empty rules as +/// . +/// +internal sealed class DoubleArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableDoubleArray }; + + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, double[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"DoubleArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteDouble(element); + } + return ValueTask.CompletedTask; + } + + public override double[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"DoubleArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate."); + } + var array = new double[length]; + for (var i = 0; i < length; i++) + { + array[i] = reader.ReadDouble(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/DoubleDataConverter.cs b/src/Geode.Client/Protocol/Serialization/DoubleDataConverter.cs new file mode 100644 index 0000000..8926072 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/DoubleDataConverter.cs @@ -0,0 +1,30 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (60). Wire payload is 8 bytes +/// IEEE-754 big-endian, no length prefix. Mirrors cppcache +/// CacheableDouble (cppcache/src/CacheableBuiltins.cpp +/// toData / fromData). +/// +/// +/// NaN / ±??wire shapes are JVM-identical (Java +/// Double.doubleToRawLongBits ??.NET +/// ). Same key +/// caveats as : legal but +/// impractical. +/// +internal sealed class DoubleDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableDouble }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, double value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteDouble(value); + return ValueTask.CompletedTask; + } + + public override double Read(DataInput reader, byte dsCode, int depth) => + reader.ReadDouble(); +} diff --git a/src/Geode.Client/Protocol/Serialization/HashSetDataConverter.cs b/src/Geode.Client/Protocol/Serialization/HashSetDataConverter.cs new file mode 100644 index 0000000..6a06525 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/HashSetDataConverter.cs @@ -0,0 +1,118 @@ +using System.Collections; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for HashSet<T> / +/// ISet<T> ?? (66). +/// Wire payload is a VL-encoded length followed by N fully-serialised +/// objects ??each element starts with its own DSCode byte. Mirrors +/// cppcache CacheableHashSet + the generic +/// writeObject(unordered_set) in +/// cppcache/include/geode/Serializer.hpp:381-388. +/// +/// +/// +/// Open-generic registration. returns +/// typeof(HashSet<>); the registry's WriteObject +/// dispatch falls back to +/// when the closed-type lookup misses, so this single instance handles +/// HashSet<int>, HashSet<string>, and every +/// other closed HashSet<T>. +/// +/// +/// Read returns canonical HashSet<object?>. Java's +/// wire format does not encode the container element type ??each slot +/// carries its own DSCode ??so target-shape conversion (to +/// HashSet<int>, ISet<string>, ?? happens +/// later at in +/// , not here. The +/// canonical decode keeps an object? element type so a null on +/// the wire survives the read (Java's HashSet permits one +/// null even though std::unordered_set does not). +/// +/// +/// Count-before-iterate. HashSet<T> deliberately +/// does not implement non-generic +/// (unlike List<T> / Dictionary<,>), so we +/// can't read Count off a type-erased cast. The wire format +/// puts the length first, so we collect once into a scratch +/// List<object?> to learn the count, then iterate that +/// list. One extra O(N) allocation; same trade-off as +/// Enumerable.ToList. +/// +/// +/// Iteration order is non-deterministic ??same as cppcache's +/// std::unordered_set. Round-trip equality must treat the wire +/// output as set-equal, not sequence-equal. +/// +/// +internal sealed class HashSetDataConverter : IDataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableHashSet }; + + private readonly SerializationRegistry _registry; + + public HashSetDataConverter(SerializationRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public byte[] DsCodes => _dsCodes; + + /// + /// Open-generic HashSet<>. Registry's write dispatch + /// reaches this converter via + /// when the closed-type + /// lookup for a concrete HashSet<T> misses. + /// + public Type ManagedType => typeof(HashSet<>); + + public byte GetDsCode(object value) => DSCode.CacheableHashSet; + + public async ValueTask WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct) + { + var source = (IEnumerable)value; + var items = new List(); + foreach (var item in source) items.Add(item); + + if (items.Count > _registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"HashSetDataConverter: cannot serialise a set of {items.Count} elements " + + $"— exceeds Serialization.MaxArrayLength ({_registry.MaxArrayLength})."); + } + writer.WriteArrayLen(items.Count); + foreach (var item in items) + { + await _registry.WriteObjectAsync(writer, item, depth + 1, ct); + } + } + + public object? Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return new HashSet(); + } + if (length > _registry.MaxArrayLength) + { + throw new GeodeException( + $"HashSetDataConverter: wire set length {length} exceeds " + + $"Serialization.MaxArrayLength ({_registry.MaxArrayLength}) ??refusing to allocate."); + } + + var set = new HashSet(capacity: length); + for (var i = 0; i < length; i++) + { + // Java permits one null in a HashSet; HashSet + // mirrors that. Duplicate elements (whatever the wire + // sends) are silently de-duplicated ??same semantics as + // std::unordered_set::insert ignoring existing keys. + set.Add(_registry.ReadObject(reader, depth + 1)); + } + return set; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/IDataConverter.cs b/src/Geode.Client/Protocol/Serialization/IDataConverter.cs new file mode 100644 index 0000000..dcb1627 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/IDataConverter.cs @@ -0,0 +1,126 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// Codec for one built-in DSCode type pair (e.g. +/// ??). Mirrors +/// cppcache Serializable family +/// (cppcache/include/geode/Serializable.hpp) but expressed as +/// an external codec object rather than a method on the value itself +/// ??primitives (int, string) can't be modified to +/// implement an interface, so a sidecar codec keeps the design +/// uniform. +/// +/// +/// +/// Always internal. Built-in DSCode types are a closed set; +/// users that need custom types go through PDX +/// (, Phase 2+) which is the public +/// extension surface. Adding new built-in DSCodes is a maintainer +/// activity, not a user activity. +/// +/// +/// One converter, possibly many DSCodes. Most converters +/// handle exactly one wire DSCode (int ??/// ). string is special: +/// one converter handles four DSCodes (CacheableASCIIString / +/// ?�ASCIIStringHuge / CacheableString / +/// ?�StringHuge) and picks which one at +/// time based on content. The array is the +/// decode-side index; resolves the +/// encode-side choice. +/// +/// +/// DSCode byte ownership. The registry writes / reads the +/// DSCode byte on both sides of the wire; converters only handle +/// payload. The byte is passed back to the converter +/// ( / ) so multi-DSCode +/// converters can branch without re-scanning. Mirrors cppcache +/// DataOutput::writeObject which calls +/// ptr->getDsCode() then writes the byte then calls +/// ptr->toData(*this). +/// +/// +/// The two-layer split below +/// ( + +/// + ): +/// +/// +/// Non-generic ??what +/// SerializationRegistry stores. Heterogeneous storage +/// (Dictionary<byte, IDataConverter>) needs an +/// erased base; that's this one. +/// Generic ??what +/// implementers write against; compile-time type safety on +/// / . +/// Abstract ??bridges +/// the two so concrete codecs only override the typed +/// methods, never the overloads. +/// +/// +internal interface IDataConverter +{ + /// + /// All wire DSCode tags this converter handles. Used as the + /// decode-side registry index; the registry registers one entry + /// per element pointing at the same converter instance. Single + /// element for most converters; four for string. Mirrors + /// the implicit one-DSCode-per-class layout cppcache enforces via + /// Serializable::getDsCode() ??we generalise to many + /// because .NET represents string as a single CLR type. + /// + byte[] DsCodes { get; } + + /// + /// CLR type this converter handles. Used as the registry encode + /// key (runtime type ??codec lookup). Cppcache's runtime type + /// system is implicit through typeid; we make it explicit + /// because .NET dictionary keys need it. + /// + Type ManagedType { get; } + + /// + /// Pick which DSCode to emit for . Most + /// converters return their sole entry; + /// string's converter inspects the content and picks one + /// of four. Mirrors cppcache Serializable::getDsCode() + /// (which is parameterless because each cppcache instance carries + /// its DSCode; we make it stateless by passing the value in). + /// + byte GetDsCode(object value); + + /// + /// Write 's payload to . + /// The DSCode byte is NOT written here — the registry writes it + /// before delegating in, then passes the byte back as + /// so multi-DSCode converters can branch. + /// + /// + /// Current nesting level — 0 at the top-level call. + /// Container converters MUST forward depth + 1 when re-entering + /// . + /// + ValueTask WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct); + + /// + /// Read one payload from . The DSCode + /// byte has already been consumed by the registry (used for codec + /// lookup) and is passed back as so + /// multi-DSCode converters know which format the payload is in. + /// Single-DSCode converters ignore it. + /// + /// + /// Current nesting level ??see + /// + /// for semantics. Container converters forward depth + 1 + /// when re-entering + /// for each element. + /// + /// + /// Boxed instance of , or null + /// for value types whose stored representation is "no value". + /// + object? Read(DataInput reader, byte dsCode, int depth); + + /// Async 版本的 ;default interface method,wrap sync。 + ValueTask ReadAsync(DataInput reader, byte dsCode, int depth, CancellationToken ct) => + ValueTask.FromResult(Read(reader, dsCode, depth)); +} diff --git a/src/Geode.Client/Protocol/Serialization/IDataConverter`1.cs b/src/Geode.Client/Protocol/Serialization/IDataConverter`1.cs new file mode 100644 index 0000000..8539fd1 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/IDataConverter`1.cs @@ -0,0 +1,30 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// Strongly-typed variant of . Concrete +/// codecs ( derivatives) implement this +/// so the boxing only happens at the +/// registry boundary, not inside the codec itself. +/// +/// CLR type the codec serialises. +internal interface IDataConverter : IDataConverter +{ + /// + /// Typed counterpart to + /// ; no boxing. + /// + byte GetDsCode(T value); + + /// Typed async 版,no boxing。 + ValueTask WriteAsync(DataOutput writer, T value, byte dsCode, int depth, CancellationToken ct); + + /// + /// Typed counterpart to + /// ; + /// no boxing. + /// + new T? Read(DataInput reader, byte dsCode, int depth); + + /// Typed async 版,no boxing。 + new ValueTask ReadAsync(DataInput reader, byte dsCode, int depth, CancellationToken ct); +} diff --git a/src/Geode.Client/Protocol/Serialization/Int16ArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/Int16ArrayDataConverter.cs new file mode 100644 index 0000000..5c0b588 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/Int16ArrayDataConverter.cs @@ -0,0 +1,62 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (47). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by 2 bytes big-endian +/// per element. Mirrors cppcache CacheableInt16Array +/// (CacheableArrayPrimitive<int16_t, CacheableInt16Array>). +/// +/// +/// Per-element wire shape matches +/// (DSCode 56). Same key / null / empty rules as +/// . +/// +internal sealed class Int16ArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableInt16Array }; + + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, short[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"Int16ArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteInt16(element); + } + return ValueTask.CompletedTask; + } + + public override short[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"Int16ArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate."); + } + var array = new short[length]; + for (var i = 0; i < length; i++) + { + array[i] = reader.ReadInt16(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/Int16DataConverter.cs b/src/Geode.Client/Protocol/Serialization/Int16DataConverter.cs new file mode 100644 index 0000000..5b308a3 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/Int16DataConverter.cs @@ -0,0 +1,23 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (56). Wire payload is 2 bytes +/// big-endian, no length prefix. Mirrors cppcache +/// CacheableInt16 (cppcache/src/CacheableBuiltins.cpp +/// toData / fromData). +/// +internal sealed class Int16DataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableInt16 }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, short value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteInt16(value); + return ValueTask.CompletedTask; + } + + public override short Read(DataInput reader, byte dsCode, int depth) => + reader.ReadInt16(); +} diff --git a/src/Geode.Client/Protocol/Serialization/Int32ArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/Int32ArrayDataConverter.cs new file mode 100644 index 0000000..4270ff4 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/Int32ArrayDataConverter.cs @@ -0,0 +1,62 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (48). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by 4 bytes big-endian +/// per element. Mirrors cppcache CacheableInt32Array +/// (CacheableArrayPrimitive<int32_t, CacheableInt32Array>). +/// +/// +/// Per-element wire shape matches +/// (DSCode 57). Same key / null / empty rules as +/// . +/// +internal sealed class Int32ArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableInt32Array }; + + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, int[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"Int32ArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteInt32(element); + } + return ValueTask.CompletedTask; + } + + public override int[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"Int32ArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate."); + } + var array = new int[length]; + for (var i = 0; i < length; i++) + { + array[i] = reader.ReadInt32(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/Int32DataConverter.cs b/src/Geode.Client/Protocol/Serialization/Int32DataConverter.cs new file mode 100644 index 0000000..f0cf74d --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/Int32DataConverter.cs @@ -0,0 +1,23 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (57). Wire payload is 4 bytes +/// big-endian, no length prefix. Mirrors cppcache +/// CacheableInt32 (cppcache/src/CacheableBuiltins.cpp +/// toData / fromData). +/// +internal sealed class Int32DataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableInt32 }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, int value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteInt32(value); + return ValueTask.CompletedTask; + } + + public override int Read(DataInput reader, byte dsCode, int depth) => + reader.ReadInt32(); +} diff --git a/src/Geode.Client/Protocol/Serialization/Int64ArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/Int64ArrayDataConverter.cs new file mode 100644 index 0000000..d11ae37 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/Int64ArrayDataConverter.cs @@ -0,0 +1,62 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (49). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by 8 bytes big-endian +/// per element. Mirrors cppcache CacheableInt64Array +/// (CacheableArrayPrimitive<int64_t, CacheableInt64Array>). +/// +/// +/// Per-element wire shape matches +/// (DSCode 58). Same key / null / empty rules as +/// . +/// +internal sealed class Int64ArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableInt64Array }; + + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, long[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"Int64ArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteInt64(element); + } + return ValueTask.CompletedTask; + } + + public override long[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"Int64ArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate."); + } + var array = new long[length]; + for (var i = 0; i < length; i++) + { + array[i] = reader.ReadInt64(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/Int64DataConverter.cs b/src/Geode.Client/Protocol/Serialization/Int64DataConverter.cs new file mode 100644 index 0000000..4680c35 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/Int64DataConverter.cs @@ -0,0 +1,23 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (58). Wire payload is 8 bytes +/// big-endian, no length prefix. Mirrors cppcache +/// CacheableInt64 (cppcache/src/CacheableBuiltins.cpp +/// toData / fromData). +/// +internal sealed class Int64DataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableInt64 }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, long value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteInt64(value); + return ValueTask.CompletedTask; + } + + public override long Read(DataInput reader, byte dsCode, int depth) => + reader.ReadInt64(); +} diff --git a/src/Geode.Client/Protocol/Serialization/LinkedListDataConverter.cs b/src/Geode.Client/Protocol/Serialization/LinkedListDataConverter.cs new file mode 100644 index 0000000..c42dcd1 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/LinkedListDataConverter.cs @@ -0,0 +1,97 @@ +using System.Collections; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for LinkedList<T> ??/// (10). Wire payload is +/// identical to ??VL-encoded length +/// followed by N fully-serialised elements ??because cppcache backs +/// both CacheableArrayList and CacheableLinkedList with +/// the same std::vector<CacheablePtr> (see +/// cppcache/include/geode/CacheableBuiltins.hpp:348-358). The +/// DSCode is what makes the server materialise a +/// java.util.LinkedList instead of a java.util.ArrayList. +/// +/// +/// +/// Open-generic registration. returns +/// typeof(LinkedList<>); the registry's WriteObject +/// dispatch falls back to +/// when the closed-type lookup misses, so this single instance handles +/// every closed LinkedList<T>. +/// +/// +/// Not IList<T>-compatible. Unlike +/// List<T>, LinkedList<T> only implements +/// / +/// ??it deliberately does not implement +/// because indexed access is O(N) on a linked list. Callers wanting a +/// linked-list-shaped region value must declare +/// IRegion<K, LinkedList<T>>, not +/// IRegion<K, IList<T>>. +/// +/// +/// Read returns canonical LinkedList<object?>. +/// Target-shape conversion (to LinkedList<int>) happens +/// at 's +/// LinkedList<> branch. +/// +/// +internal sealed class LinkedListDataConverter : IDataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableLinkedList }; + + private readonly SerializationRegistry _registry; + + public LinkedListDataConverter(SerializationRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public byte[] DsCodes => _dsCodes; + + public Type ManagedType => typeof(LinkedList<>); + + public byte GetDsCode(object value) => DSCode.CacheableLinkedList; + + public async ValueTask WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct) + { + var source = (ICollection)value; + if (source.Count > _registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"LinkedListDataConverter: cannot serialise a list of {source.Count} elements " + + $"— exceeds Serialization.MaxArrayLength ({_registry.MaxArrayLength})."); + } + writer.WriteArrayLen(source.Count); + foreach (var item in source) + { + await _registry.WriteObjectAsync(writer, item, depth + 1, ct); + } + } + + public object? Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + var list = new LinkedList(); + if (length <= 0) + { + return list; + } + if (length > _registry.MaxArrayLength) + { + throw new GeodeException( + $"LinkedListDataConverter: wire list length {length} exceeds " + + $"Serialization.MaxArrayLength ({_registry.MaxArrayLength}) ??refusing to allocate."); + } + + for (var i = 0; i < length; i++) + { + // AddLast preserves wire order ??wire element 0 becomes + // head, last element becomes tail. + list.AddLast(_registry.ReadObject(reader, depth + 1)); + } + return list; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/ListDataConverter.cs b/src/Geode.Client/Protocol/Serialization/ListDataConverter.cs new file mode 100644 index 0000000..023fb3c --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/ListDataConverter.cs @@ -0,0 +1,114 @@ +using System.Collections; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for List<T> / +/// IList<T> ?? (65). +/// Wire payload is a VL-encoded length followed by N fully-serialised +/// objects ??each element starts with its own DSCode byte (including +/// for nulls). Mirrors cppcache +/// CacheableArrayList +/// (cppcache/src/CacheableArrayList.cpp). +/// +/// +/// +/// Open-generic registration. returns +/// typeof(List<>); the registry's WriteObject +/// dispatch falls back to +/// when the closed-type lookup misses, so this single instance handles +/// List<int>, List<string>, and every other +/// closed List<T>. +/// +/// +/// Read returns canonical List<object?>. Java's +/// wire format does not encode the container element type ??each slot +/// carries its own DSCode ??so target-shape conversion happens later +/// at in +/// , not here. +/// +/// +/// null elements survive the round trip: the registry +/// emits for any null passed to +/// , and decodes that +/// DSCode back to null. A top-level null list is +/// intercepted by the registry one level higher and never reaches this +/// converter. +/// +/// +/// Registry back-reference. Same pattern as +/// / +/// : each element re-enters +/// / +/// so any registered +/// type (including nested lists / arrays) can occupy a slot. Safe +/// this pass at registry construction ??we store the reference +/// but only invoke through it later, by which point the registry is +/// fully populated. +/// +/// +internal sealed class ListDataConverter : IDataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableArrayList }; + + private readonly SerializationRegistry _registry; + + public ListDataConverter(SerializationRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public byte[] DsCodes => _dsCodes; + + /// + /// Open-generic List<>. The registry's write dispatch + /// reaches this converter via + /// when the closed-type lookup for a concrete List<T> + /// misses. + /// + public Type ManagedType => typeof(List<>); + + public byte GetDsCode(object value) => DSCode.CacheableArrayList; + + public async ValueTask WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct) + { + var source = (IList)value; + if (source.Count > _registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"ListDataConverter: cannot serialise a list of {source.Count} elements " + + $"— exceeds Serialization.MaxArrayLength ({_registry.MaxArrayLength})."); + } + writer.WriteArrayLen(source.Count); + foreach (var item in source) + { + await _registry.WriteObjectAsync(writer, item, depth + 1, ct); + } + } + + public object? Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return new List(0); + } + if (length > _registry.MaxArrayLength) + { + throw new GeodeException( + $"ListDataConverter: wire list length {length} exceeds " + + $"Serialization.MaxArrayLength ({_registry.MaxArrayLength}) ??refusing to allocate."); + } + + var list = new List(length); + for (var i = 0; i < length; i++) + { + // Each slot's DSCode is read by ReadObject. Null elements + // come back as null via DSCode.NullObj. Any registered + // type (including a nested ArrayList) is a valid slot. + list.Add(_registry.ReadObject(reader, depth + 1)); + } + return list; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/ObjectArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/ObjectArrayDataConverter.cs new file mode 100644 index 0000000..86a425b --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/ObjectArrayDataConverter.cs @@ -0,0 +1,134 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (52). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by a Java class header +/// (one tag byte + the string +/// "java.lang.Object" via the standard string-write path) +/// followed by N fully-serialised objects ??each element starts +/// with its own DSCode byte (including for +/// null elements). Mirrors cppcache CacheableObjectArray +/// (cppcache/src/CacheableObjectArray.cpp). +/// +/// +/// +/// The class-name header is part of the wire format, not metadata +/// we can drop. Java's DataSerializer writes an Object[] +/// as arrayLength ??componentTypeName ??elements. We write +/// the fixed string "java.lang.Object" (matching cppcache ??/// we don't preserve the .NET runtime element type) and on read we +/// consume the bytes without using them: the wire dictates the +/// element type sequence per-element via each element's DSCode, so +/// the header is informational only on this side. +/// +/// +/// Why a registry reference: each element re-enters +/// / +/// so any registered +/// type can appear in a slot (string, int, bool, even nested arrays). +/// Same pattern as . Passing +/// the registry through the constructor keeps the converter free of +/// direct knowledge about which converters handle which element +/// type. +/// +/// +/// null elements survive the round trip: the registry +/// emits for any null value passed to +/// , and decodes +/// DSCode 41 back to null. A top-level null +/// [] (the array itself being null) is +/// intercepted by the registry one level higher and never reaches +/// this converter. +/// +/// +/// Type identity: typeof(object[]) only matches values +/// whose runtime type is exactly object[]. A string[] +/// or int[] stored in an object variable is still +/// string[] / int[] at +/// time, so they dispatch to +/// / respectively ??not here. +/// To force polymorphic element types on the wire, the caller must +/// explicitly allocate new object[] { ... }. +/// +/// +internal sealed class ObjectArrayDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableObjectArray }; + + private const string JavaObjectClassName = "java.lang.Object"; + + private readonly SerializationRegistry _registry; + + /// + /// Takes the owning so each + /// element can re-enter + /// /. The + /// this-reference at registry-construction time is safe + /// for the same reason as + /// ??we only store the + /// reference and call it later from / + /// , by which point the registry is fully + /// populated. + /// + public ObjectArrayDataConverter(SerializationRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public override byte[] DsCodes => _dsCodes; + + public override async ValueTask WriteAsync(DataOutput writer, object[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"ObjectArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_registry.MaxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + writer.WriteByte(DSCode.Class); + writer.WriteString(JavaObjectClassName); + + foreach (var element in value) + { + await _registry.WriteObjectAsync(writer, element, depth + 1, ct); + } + } + + public override object[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _registry.MaxArrayLength) + { + throw new GeodeException( + $"ObjectArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_registry.MaxArrayLength}) ??refusing to allocate."); + } + + // Discard the class header ??its information is redundant + // with the per-element DSCode bytes that follow. cppcache's + // fromData reads + ignores these too. + // reader.ReadByte() ??DSCode.Class tag + // _registry.ReadObject() ??the "java.lang.Object" string, + // routed via StringDataConverter + reader.ReadByte(); + _registry.ReadObject(reader, depth + 1); + + var array = new object[length]; + for (var i = 0; i < length; i++) + { + // Element slot is object ??any registered type (including + // null via DSCode.NullObj) is a valid value. The "!" is a + // CS8601 dance: the slot's static type is non-nullable + // object, but at runtime CLR arrays of reference types + // accept null in any slot. Tested in + // ObjectArrayDataConverterTests.RoundTrip_with_null_elements. + array[i] = _registry.ReadObject(reader, depth + 1)!; + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxField.cs b/src/Geode.Client/Protocol/Serialization/PdxField.cs new file mode 100644 index 0000000..e50fdde --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxField.cs @@ -0,0 +1,116 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// One PDX field's schema metadata. Mirror of cppcache PdxFieldType +/// (cppcache/src/PdxFieldType.hpp) — name kept distinct from +/// the enum. +/// +/// Field name as written in ToData. +/// Wire type tag. +/// Field order within this schema (0-based). +/// +/// for primitives (no offset-table entry); +/// for var-len fields (string, byte[], object, …). +/// +/// +/// Slot index in the trailing offset table for var-len fields (0-based, +/// in declaration order). Unused for fixed-size fields (pass -1). +/// +internal sealed record PdxField( + string Name, + PdxFieldType Type, + int Index, + bool IsFixedSize, + int VarLenFieldIdx = -1) +{ + /// + /// Fixed wire width in bytes for fixed-size types; 0 for + /// var-len types. Mirrors cppcache PdxTypes::kPdxXxxSize + /// constants (cppcache/src/PdxTypes.hpp). + /// + public int FixedSize => Type switch + { + PdxFieldType.Boolean or PdxFieldType.Byte => 1, + PdxFieldType.Char or PdxFieldType.Short => 2, + PdxFieldType.Int or PdxFieldType.Float => 4, + PdxFieldType.Long or PdxFieldType.Double or PdxFieldType.Date => 8, + _ => 0, + }; + + /// + /// Offset-table entry to read when locating this field on the wire; + /// -1 = use the PDX length header (field lives in the fixed + /// suffix), 0 = no offset lookup needed (field lives in the + /// fixed prefix before any var-len). Stamped by + /// 's position-map pass. + /// + public int VarLenOffsetIndex { get; set; } + + /// + /// Signed delta to apply on top of the resolved offset. Lets + /// fixed-size neighbours of a var-len anchor share its offset slot + /// via constant adjustments. Stamped by + /// 's position-map pass. + /// + public int RelativeOffset { get; set; } + + /// + /// Identity equality on (, , + /// ) — ignores and the + /// var-len indices so two schemas with reordered fields can still + /// recognise "this is the same field". Used by + /// for the local↔remote field diff. + /// Mirror of cppcache PdxFieldType::equals. + /// + public bool SameField(PdxField other) => + Name == other.Name && Type == other.Type && IsFixedSize == other.IsFixedSize; + + /// + /// when this field participates in the schema's + /// identity hash/equality. Mirror of cppcache m_isIdentityField; + /// defaults to (cppcache PdxFieldType.cpp:66). + /// + /// + /// Identity-field marking is the user-facing + /// IPdxWriter.MarkIdentityField opt-in (Phase 2.x). Until that + /// API lands, no field is identity-marked and ToData writes + /// for all fields. + /// + public bool IsIdentityField { get; init; } + + /// + /// Serialise this field's schema entry into + /// as part of 's field-table loop. Mirror + /// of cppcache PdxFieldType::toData + /// (cppcache/src/PdxFieldType.cpp:88). + /// + /// + /// Wire layout (cppcache PdxFieldType.cpp:88-97): + /// + /// str Name + /// i32 Index (cppcache m_sequenceId) + /// i32 VarLenFieldIdx (0 for fixed-size fields — see note below) + /// i8 (sbyte)Type (PdxFieldType enum value) + /// i32 RelativeOffset + /// i32 VarLenOffsetIndex (cppcache m_vlOffsetIndex) + /// bool IsIdentityField + /// + /// + /// VarLenFieldIdx wire compat. Our record uses -1 as + /// the default sentinel for fixed-size fields (semantic "no slot"); + /// cppcache stores 0 in the same case (PdxType.cpp:138 + /// last ctor arg). Wire shape has to match cppcache, so we project + /// -10 at this boundary. + /// + /// + public void ToData(DataOutput output) + { + output.WriteString(Name); + output.WriteInt32(Index); + output.WriteInt32(IsFixedSize ? 0 : VarLenFieldIdx); + output.WriteByte((byte)(sbyte)Type); + output.WriteInt32(RelativeOffset); + output.WriteInt32(VarLenOffsetIndex); + output.WriteBool(IsIdentityField); + } +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxFieldType.cs b/src/Geode.Client/Protocol/Serialization/PdxFieldType.cs new file mode 100644 index 0000000..a4e99ce --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxFieldType.cs @@ -0,0 +1,32 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// PDX field type tags. Mirror of cppcache PdxFieldTypes +/// (cppcache/include/geode/PdxFieldTypes.hpp). +/// +internal enum PdxFieldType +{ + Unknown = -1, + Boolean = 0, + Byte = 1, + Char = 2, + Short = 3, + Int = 4, + Long = 5, + Float = 6, + Double = 7, + Date = 8, + String = 9, + Object = 10, + BooleanArray = 11, + CharArray = 12, + ByteArray = 13, + ShortArray = 14, + IntArray = 15, + LongArray = 16, + FloatArray = 17, + DoubleArray = 18, + StringArray = 19, + ObjectArray = 20, + ArrayOfByteArrays = 21, +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxLocalWriter.cs b/src/Geode.Client/Protocol/Serialization/PdxLocalWriter.cs new file mode 100644 index 0000000..921c9d6 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxLocalWriter.cs @@ -0,0 +1,230 @@ +using System.Buffers.Binary; +using Geode.Client.Pdx; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// Encodes a PDX object's payload while running user's +/// . Mirror of cppcache +/// PdxLocalWriter (cppcache/src/PdxLocalWriter.hpp). +/// +internal class PdxLocalWriter(IServiceProvider serviceProvider) + : IPdxWriter, IDisposable +{ + // Wire layout (excluding leading DSCode.PDX byte written by + // SerializationRegistry.TryWritePdx): + // PdxLength (4 BE) + // TypeId (4 BE) + // + // + + // Internal scratch DataOutput resolved via DI (consistency with the + // rest of the wire codec). PDX field writes never recurse into + // SerializationRegistry — DI hands us one but it sits unused; the + // overhead is one field, accepted for uniform construction style. + private readonly DataOutput _output = ActivatorUtilities.CreateInstance(serviceProvider); + private readonly List _fields = []; + private readonly List _varLenOffsets = []; + + /// + /// 子類用:把目前累積的 field list 包成 。對應 + /// cppcache PdxWriterWithTypeCollector::getPdxLocalType() 從 base + /// 拿 m_pdxType 的動作 — 我們蒐 field 在 base 做,所以這裡幫子類 + /// 把它取出來。 + /// + protected PdxType BuildSchema(string className) + { + return ActivatorUtilities.CreateInstance(serviceProvider, className, _fields); + } + private readonly StringDataConverter _stringConverter = new(serviceProvider.GetRequiredService()); + + public IPdxWriter WriteBoolean(string fieldName, bool value) + { + AddFixedField(fieldName, PdxFieldType.Boolean); + _output.WriteBool(value); + return this; + } + + public IPdxWriter WriteByte(string fieldName, sbyte value) + { + AddFixedField(fieldName, PdxFieldType.Byte); + _output.WriteSByte(value); + return this; + } + + public IPdxWriter WriteChar(string fieldName, char value) + { + AddFixedField(fieldName, PdxFieldType.Char); + _output.WriteUInt16(value); + return this; + } + + public IPdxWriter WriteShort(string fieldName, short value) + { + AddFixedField(fieldName, PdxFieldType.Short); + _output.WriteInt16(value); + return this; + } + + public IPdxWriter WriteInt(string fieldName, int value) + { + AddFixedField(fieldName, PdxFieldType.Int); + _output.WriteInt32(value); + return this; + } + + public IPdxWriter WriteLong(string fieldName, long value) + { + AddFixedField(fieldName, PdxFieldType.Long); + _output.WriteInt64(value); + return this; + } + + public IPdxWriter WriteFloat(string fieldName, float value) + { + AddFixedField(fieldName, PdxFieldType.Float); + _output.WriteFloat(value); + return this; + } + + public IPdxWriter WriteDouble(string fieldName, double value) + { + AddFixedField(fieldName, PdxFieldType.Double); + _output.WriteDouble(value); + return this; + } + + public IPdxWriter WriteDate(string fieldName, DateTime value) + { + // Wire form: signed int64 BE = milliseconds since 1970-01-01 UTC + // (Java Date(long)). Reject Unspecified to avoid silent local-tz + // assumption — same rule as DateTimeDataConverter. + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + DateTimeKind.Unspecified => throw new ArgumentException( + "DateTime with Kind=Unspecified cannot be serialised: " + + "the wire form is UTC milliseconds and Unspecified would " + + "force a silent local-timezone assumption.", + nameof(value)), + _ => throw new ArgumentOutOfRangeException(nameof(value)), + }; + var ms = (utc - DateTime.UnixEpoch).Ticks / TimeSpan.TicksPerMillisecond; + + AddFixedField(fieldName, PdxFieldType.Date); + _output.WriteInt64(ms); + return this; + } + + public IPdxWriter WriteString(string fieldName, string? value) + { + // Push offset BEFORE writing so reader knows where this var-len + // field starts. cppcache PdxLocalWriter::writeString calls + // addOffset() before m_dataOutput->writeString. + _varLenOffsets.Add(_output.WrittenCount); + AddVarLenField(fieldName, PdxFieldType.String); + + if (value is null) + { + // cppcache writeString(nullptr) → DSCode.CacheableNullString (69). + // Distinct from generic DSCode.NullObj (41) used by WriteObject(null). + _output.WriteByte(DSCode.CacheableNullString); + return this; + } + + // Reuse Phase 1's StringDataConverter so max-length, DSCode + // selection (ASCII / huge / mod UTF-8 / UTF-16) and payload + // encoding stay symmetric with non-PDX strings。IPdxWriter 是 sync + // 介面(user ToData 不會 await),而 StringDataConverter 在 + // async 化之後只剩 WriteAsync;但 string encoding 純 CPU、不會真 + // await,所以這裡 block 一下是 no-op。 + var dsCode = _stringConverter.GetDsCode(value); + _output.WriteByte(dsCode); + _stringConverter.WriteAsync(_output, value, dsCode, depth: 0, ct: default) + .AsTask().GetAwaiter().GetResult(); + return this; + } + + /// + /// Finalize the field-data payload(field bytes + offset table)。對應 + /// cppcache PdxLocalWriter::endObjectWriting + writeOffsets + /// — 但**不含** wire-level header(DSCode.PDX/length/typeId), + /// 那是 caller(SerializationRegistry.TryWritePdxAsync) + /// 寫到外層 。 + /// + public byte[] BuildPayload() + { + var fieldData = _output.WrittenSpan; + + // Offset table: numVarLen - 1 entries (first var-len's offset is + // implicit at 0, so it's elided). cppcache PdxLocalWriter:: + // writeOffsets writes them in reverse order at the tail of the + // payload, with width chosen from total payload length. + int numEntries = Math.Max(0, _varLenOffsets.Count - 1); + if (numEntries == 0) + { + return fieldData.ToArray(); + } + + var (width, totalLen) = PickOffsetWidth(fieldData.Length, numEntries); + var payload = new byte[totalLen]; + fieldData.CopyTo(payload); + + int pos = fieldData.Length; + for (int i = _varLenOffsets.Count - 1; i > 0; i--) + { + int offset = _varLenOffsets[i]; + switch (width) + { + case 1: + payload[pos] = (byte)offset; + pos += 1; + break; + case 2: + BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(pos), (ushort)offset); + pos += 2; + break; + default: // 4 + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(pos), offset); + pos += 4; + break; + } + } + + return payload; + } + + public void Dispose() => _output.Dispose(); + + /// + /// Pick the smallest offset-entry width that keeps the total payload + /// (field data + offset table) within the chosen-width range. + /// Mirrors cppcache PdxLocalWriter::calculateLenWithOffsets. + /// + private static (int Width, int TotalLen) PickOffsetWidth(int fieldDataLen, int numEntries) + { + int probe1 = fieldDataLen + numEntries; + if (probe1 <= 0xFF) return (1, probe1); + + int probe2 = fieldDataLen + numEntries * 2; + if (probe2 <= 0xFFFF) return (2, probe2); + + return (4, fieldDataLen + numEntries * 4); + } + + private void AddFixedField(string name, PdxFieldType type) => + _fields.Add(new PdxField(name, type, Index: _fields.Count, IsFixedSize: true)); + + private void AddVarLenField(string name, PdxFieldType type) => + _fields.Add(new PdxField( + name, type, Index: _fields.Count, IsFixedSize: false, + // _varLenOffsets is appended to BEFORE this call (see WriteString), + // so Count-1 = the slot id just claimed for this field. + VarLenFieldIdx: _varLenOffsets.Count - 1)); +} + diff --git a/src/Geode.Client/Protocol/Serialization/PdxRemotePreservedData.cs b/src/Geode.Client/Protocol/Serialization/PdxRemotePreservedData.cs new file mode 100644 index 0000000..5bc3ed6 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxRemotePreservedData.cs @@ -0,0 +1,17 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// Holds the unread-field bytes a previous deserialize captured for a PDX +/// object, plus the merged typeId that schema lives under。Mirror of cppcache +/// PdxRemotePreservedData(cppcache/src/PdxRemotePreservedData.hpp)。 +/// +/// +/// 等 Step B 路徑接通(PdxTypeRegistry.GetPreserveData / +/// SetPreserveData)再把欄位填齊。目前只放 placeholder MergedTypeId, +/// 讓 Step B.2 的 new PdxRemoteWriter(output, mergedPdxType, +/// preservedData, registry) ctor 簽名能引到。 +/// +internal sealed class PdxRemotePreservedData +{ + public int MergedTypeId { get; init; } = -1; +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxRemoteWriter.cs b/src/Geode.Client/Protocol/Serialization/PdxRemoteWriter.cs new file mode 100644 index 0000000..736d8cc --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxRemoteWriter.cs @@ -0,0 +1,48 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// Encodes a PDX object's payload using a remote (server-known) PdxType's +/// field layout, plus any preserved unread fields. Mirror of cppcache +/// PdxRemoteWriter(cppcache/src/PdxRemoteWriter.hpp)。 +/// +internal sealed class PdxRemoteWriter : PdxLocalWriter +{ + /// + /// 沒有 preserved data 時用:caller 只給 className,後面要照本地 schema + /// 寫 wire bytes。對應 cppcache + /// PdxRemoteWriter(DataOutput&, std::string pdxClassName, PdxTypeRegistry)。 + /// + public PdxRemoteWriter(IServiceProvider serviceProvider, string className) + : base(serviceProvider) + { + ClassName = className; + } + + /// + /// 物件帶有殘留 unread fields 時用:照 merged schema 寫 + 把 unread bytes + /// 補回。對應 cppcache + /// PdxRemoteWriter(DataOutput&, std::shared_ptr<PdxType>, + /// std::shared_ptr<PdxRemotePreservedData>, PdxTypeRegistry)。 + /// + public PdxRemoteWriter( + IServiceProvider serviceProvider, + PdxType mergedPdxType, + PdxRemotePreservedData preservedData) + : base(serviceProvider) + { + MergedPdxType = mergedPdxType; + PreservedData = preservedData; + ClassName = mergedPdxType.ClassName; + } + + public string ClassName { get; } + + /// + /// Server 已知的 merged schema(local + remote 合併過)。只有「帶 preserved + /// data」形態才會非 null。 + /// + public PdxType? MergedPdxType { get; } + + /// 前次 deserialize 留下來的 unread fields;沒有就 null。 + public PdxRemotePreservedData? PreservedData { get; } +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxType.cs b/src/Geode.Client/Protocol/Serialization/PdxType.cs new file mode 100644 index 0000000..bf89319 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxType.cs @@ -0,0 +1,267 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// PDX class schema (ordered field list + server-assigned typeId). +/// +/// +/// populates two field-index maps: +/// +/// [localIdx]: +/// -2 = same position remotely, N ≥ 0 = remote +/// sequence id, -1 = local-only. +/// [remoteIdx]: +/// 1 = present locally, -1 = local missing +/// (var-len), -2 = local missing (fixed). +/// +/// +internal sealed class PdxType( + PdxTypeRegistry pdxTypeRegistry, + string className, + IReadOnlyList fields) +{ + private readonly Dictionary _fieldByName = []; + + /// + /// Stamp / + /// on each field and build the + /// name lookup. Mirror of cppcache generatePositionMap + /// (PdxType.cpp:489). + /// + private void GeneratePositionMap() + { + // Pass 1 — back-to-front. Each fixed-size field anchors to the + // nearest var-len behind it; var-len fields anchor to themselves. + var foundVarLen = false; + var lastVarLenSeqId = 0; + PdxField? previousField = null; + + for (int i = Fields.Count - 1; i >= 0; i--) + { + var f = Fields[i]; + _fieldByName[f.Name] = f; + + if (!f.IsFixedSize) + { + f.VarLenOffsetIndex = f.VarLenFieldIdx; + f.RelativeOffset = 0; + foundVarLen = true; + lastVarLenSeqId = f.VarLenFieldIdx; + } + else if (foundVarLen) + { + f.VarLenOffsetIndex = lastVarLenSeqId; + f.RelativeOffset = -f.FixedSize + previousField!.RelativeOffset; + } + else + { + f.VarLenOffsetIndex = -1; // use PDX length header + f.RelativeOffset = previousField is null + ? -f.FixedSize + : -f.FixedSize + previousField.RelativeOffset; + } + previousField = f; + } + + // Pass 2 — front-to-back. Overwrite pass 1 for the fixed-size + // prefix with absolute start-of-payload offsets. + foundVarLen = false; + var prevFixedSizeOffsets = 0; + for (int i = 0; i < Fields.Count && !foundVarLen; i++) + { + var f = Fields[i]; + if (!f.IsFixedSize) + { + f.VarLenOffsetIndex = -1; // first var-len + f.RelativeOffset = prevFixedSizeOffsets; + foundVarLen = true; + } + else + { + f.VarLenOffsetIndex = 0; // no offset lookup needed + f.RelativeOffset = prevFixedSizeOffsets; + prevFixedSizeOffsets += f.FixedSize; + } + } + } + + /// + /// Build . Mirror of cppcache + /// initLocalToRemote (PdxType.cpp:233). + /// + private void InitLocalToRemote() + { + var localPdxType = pdxTypeRegistry.GetLocalPdxType(ClassName); + if (localPdxType is null) return; + + var localFields = localPdxType.Fields; + var map = new int[localFields.Count]; + + // Phase 1: parallel walk while fields stay in the same order. + var fieldIdx = 0; + var commonCount = Math.Min(localFields.Count, Fields.Count); + for (; fieldIdx < commonCount; fieldIdx++) + { + if (localFields[fieldIdx].SameField(Fields[fieldIdx])) + map[fieldIdx] = -2; + else + break; + } + + // Phase 2: order diverged — linear-search remote for the rest. + for (; fieldIdx < localFields.Count; fieldIdx++) + { + var localField = localFields[fieldIdx]; + var found = false; + foreach (var remoteField in Fields) + { + if (localField.SameField(remoteField)) + { + map[fieldIdx] = remoteField.Index; + found = true; + break; + } + } + if (!found) map[fieldIdx] = -1; + } + + LocalToRemoteFieldMap = map; + } + + /// + /// Build . Mirror of cppcache + /// initRemoteToLocal (PdxType.cpp:165). + /// + private void InitRemoteToLocal() + { + var localPdxType = pdxTypeRegistry.GetLocalPdxType(ClassName); + if (localPdxType is null) return; + + var localFields = localPdxType.Fields; + var map = new int[Fields.Count]; + NumberOfFieldsExtra = 0; + + for (int i = 0; i < Fields.Count; i++) + { + var remoteField = Fields[i]; + var foundLocal = false; + foreach (var localField in localFields) + { + if (localField.SameField(remoteField)) + { + map[i] = 1; + foundLocal = true; + break; + } + } + if (!foundLocal) + { + map[i] = remoteField.IsFixedSize ? -2 : -1; + NumberOfFieldsExtra++; + } + } + RemoteToLocalFieldMap = map; + } + + /// O(1) field lookup by name; on miss. + internal PdxField? GetField(string name) => _fieldByName.GetValueOrDefault(name); + + internal int[]? LocalToRemoteFieldMap { get; private set; } + internal int[]? RemoteToLocalFieldMap { get; private set; } + internal int NumberOfFieldsExtra { get; private set; } + + /// + /// Compute the read/write lookup tables. Called by + /// SerializationRegistry Step A.3 (fresh local schema) and + /// again when a remote schema arrives via GetPdxTypeById. + /// Mirror of cppcache InitializeType() (PdxType.cpp:300). + /// + public void Initialize() + { + InitRemoteToLocal(); // write path + InitLocalToRemote(); // read path + GeneratePositionMap(); // byte-offset cache + name lookup + } + + public string ClassName => className; + + public IReadOnlyList Fields => fields; + + /// Server-assigned typeId; -1 until GET_PDX_ID_FOR_TYPE resolves. + public int TypeId { get; set; } = -1; + + /// + /// Wire-format Java class id for the PDX schema descriptor itself. + /// Mirror of cppcache PdxType::m_javaPdxClass + /// (PdxType.cpp:37). + /// + public const string JavaPdxClass = "org.apache.geode.pdx.internal.PdxType"; + + /// + /// when names a Java + /// (server-side) domain class; for + /// pure-PDX/PdxInstance schemas. Wire field at + /// PdxType::toData is the negation (noJavaClass). + /// Mirror of cppcache is_java_class_; default tracks + /// cppcache's expectDomainClass=true. + /// + public bool IsJavaClass { get; init; } = true; + + /// + /// cppcache m_varLenFieldIdx: the largest var-len slot id + /// assigned, or 0 when no var-len fields are present. + /// Mirror of cppcache PdxType.cpp:151 — increments only after + /// the first var-len field is added, so for N var-len fields the + /// value is max(0, N-1). + /// + public int VarLenFieldIdx + { + get + { + var count = 0; + foreach (var f in Fields) + { + if (!f.IsFixedSize) count++; + } + return count == 0 ? 0 : count - 1; + } + } + + /// + /// Serialise this schema into as the part + /// body of a GET_PDX_ID_FOR_TYPE (opcode 93) request. Mirror + /// of cppcache PdxType::toData (PdxType.cpp:66). + /// + /// + /// Wire layout (cppcache PdxType.cpp:66-91): + /// + /// u8 DSCode.DataSerializable (45) + /// u8 DSCode.Class (43) + /// str JavaPdxClass ("org.apache.geode.pdx.internal.PdxType") + /// str ClassName + /// bool !IsJavaClass (the wire field is "noJavaClass") + /// i32 TypeId (server reassigns; client sentinel is -1) + /// i32 VarLenFieldIdx + /// i32 Fields.Count (DataOutput.WriteArrayLen) + /// for each PdxField: PdxField.ToData(output) + /// + /// + /// Per-field body is delegated to , + /// currently a NIE leaf (cppcache PdxFieldType.cpp:88). + /// + /// + public void ToData(DataOutput output) + { + output.WriteByte(DSCode.DataSerializable); + output.WriteByte(DSCode.Class); + output.WriteString(JavaPdxClass); + output.WriteString(ClassName); + output.WriteBool(!IsJavaClass); + output.WriteInt32(TypeId); + output.WriteInt32(VarLenFieldIdx); + output.WriteArrayLen(Fields.Count); + foreach (var f in Fields) + { + f.ToData(output); + } + } +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxTypeRegistry.cs b/src/Geode.Client/Protocol/Serialization/PdxTypeRegistry.cs new file mode 100644 index 0000000..1cb811e --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxTypeRegistry.cs @@ -0,0 +1,256 @@ +using System.Collections.Concurrent; +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// Per-cache cache of PDX schema () ↔ server-assigned +/// typeId. Scoped service. Mirror of cppcache PdxTypeRegistry +/// (cppcache/src/PdxTypeRegistry.hpp). +/// +/// +/// Distinct from Services.TypeRegistry (user API — registers .NET +/// types). This is internal wire bookkeeping: typeId is server-assigned +/// per cluster, so the cache is scoped to one cache instance. +/// +/// Two separate maps, mirroring cppcache: +/// +/// _localByClassName — schemas collected locally on this +/// client (first-time serialize path). Drives the Step A vs Step B +/// branch in TryWritePdxAsync. +/// _byTypeId — every known schema keyed by server typeId, +/// including ones the server told us about during deserialize but +/// we never built locally. +/// A schema can live in _byTypeId only (server-known, no +/// local toData yet), _localByClassName only (collected but +/// typeId not assigned yet), or both (fully resolved). +/// +/// +/// +internal sealed class PdxTypeRegistry( + ILogger logger, + // IServiceProvider used as a service locator to break the + // PdxTypeRegistry ↔ SerializationRegistry ↔ TcrMessageBuilder DI cycle. + // Only the GET_PDX_ID_FOR_TYPE wire-op path resolves anything; the + // hot caches (Get/AddLocalPdxType / Get/AddPdxType) don't touch it. + IServiceProvider serviceProvider) +{ + + // ConcurrentDictionary: reads (GetLocalPdxType / GetPdxType) are hot — + // every PDX serialize hits at least one of them. Writes (AddLocalPdxType + // / AddPdxType) only fire once per unique className at Step A.7. Striped + // locks give us lock-free reads; no cross-map atomicity is required so + // each map locks independently. + private readonly ConcurrentDictionary _byTypeId = new(); + private readonly ConcurrentDictionary _localByClassName = new(); + + /// + /// Insert a locally-collected schema into the className map. + /// Mirror of cppcache PdxTypeRegistry::addLocalPdxType + /// (PdxTypeRegistry.cpp:138). + /// + public void AddLocalPdxType(string className, PdxType nType) => + _localByClassName[className] = nType; + + /// + /// Insert a typeId→schema mapping. Mirror of cppcache + /// PdxTypeRegistry::addPdxType (PdxTypeRegistry.cpp:123). + /// + public void AddPdxType(int typeId, PdxType nType) => + _byTypeId[typeId] = nType; + + public PdxType? GetLocalPdxType(string className) => + _localByClassName.TryGetValue(className, out var t) ? t : null; + + /// + /// Resolve the server-assigned typeId for a freshly-built local schema. + /// Mirror of cppcache PdxTypeRegistry::getPDXIdForType(type, pool, + /// nType, checkIfThere) (PdxTypeRegistry.cpp:49) + + /// ThinClientPoolDM::GetPDXIdForType + /// (ThinClientPoolDM.cpp:900). + /// + public async ValueTask GetPdxIdForTypeAsync(string className, IPool? pool, PdxType nType, bool checkIfThere, + CancellationToken ct) + { + if (checkIfThere && GetLocalPdxType(className) is { TypeId: > 0 } lpdx) + { + return lpdx.TypeId; + } + + var typeId = await SendGetPdxIdForTypeAsync(pool, nType, ct); + AddPdxType(typeId, nType); + return typeId; + } + + /// + /// Wire-op chunk of (G.2–G.4). + /// Mirror of cppcache ThinClientPoolDM::GetPDXIdForType + /// (ThinClientPoolDM.cpp:900). + /// + /// + /// Three prereqs: + /// + /// PdxType.ToData(DataOutput) — schema self-serialize. + /// TcrMessageBuilder.GetPdxIdForTypeAsync(PdxType, ct) — + /// header(GET_PDX_ID_FOR_TYPE=93, 1 part) + + /// ObjectPart carrying the schema bytes. + /// IPool.SendSyncRequestAsync — single endpoint send + /// + await reply, throwing on MessageType.Exception. + /// + /// + private async ValueTask SendGetPdxIdForTypeAsync(IPool? pool, PdxType nType, CancellationToken ct) + { + // Mirror cppcache ThinClientPoolDM::GetPDXIdForType entry log + // (ThinClientPoolDM.cpp:902). + logger.LogDebug( + "GetPdxIdForType: className={ClassName} fields={FieldCount}", + nType.ClassName, nType.Fields.Count); + + // S.1 Build the GET_PDX_ID_FOR_TYPE request frame + // (cppcache TcrMessageGetPdxIdForType ctor). + var request = await BuildGetPdxIdForTypeRequestAsync(nType, ct); + + // S.2 Send sync via the pool and await the reply. cppcache uses + // sendSyncRequest; ours awaits the DM API region ops use. + var reply = await SendSyncRequestAsync(pool, request, ct); + + // S.3 Server-side failure to register the schema → no recovery, + // surface as GeodeException. Mirror cppcache LOGDEBUG + // (ThinClientPoolDM.cpp:914) for trace parity. + if (reply.MessageType == MessageType.Exception) + { + var preview = DecodeExceptionPreview(reply); + logger.LogDebug("GetPdxIdForType: server exception for className={ClassName}: {Exception}", + nType.ClassName, preview); + throw new GeodeException("GET_PDX_ID_FOR_TYPE failed: " + preview); + } + + // S.4 Reply carries a single ObjectPart whose payload is a + // CacheableInt32 (DSCode 57 + 4 BE). + return await ParseInt32ReplyAsync(reply, ct); + } + + // ── S.* leaves (each maps to a missing prereq) ──────────────────── + + /// + /// Build the GET_PDX_ID_FOR_TYPE (opcode 93) request frame: + /// header(1 part) + ObjectPart carrying PdxType.ToData's bytes. + /// Resolves via the service provider + /// (rather than holding a direct field) to break the + /// PdxTypeRegistry ↔ SerializationRegistry ↔ TcrMessageBuilder DI cycle. + /// + /// + /// Still propagates NIE from TcrMessageBuilder.GetPdxIdForTypeAsync + /// — its body needs PdxType.ToData(DataOutput) to be implemented + /// before it can serialise the schema. + /// + private ValueTask BuildGetPdxIdForTypeRequestAsync(PdxType nType, CancellationToken ct) + { + throw new NotImplementedException(); + //serviceProvider.GetRequiredService() + // .GetPdxIdForTypeAsync(nType, ct); + } + + /// + /// Send through 's + /// DM and await the reply. Mirror of cppcache + /// SerializationRegistry::GetPDXIdForType's + /// dynamic_cast<ThinClientPoolDM*>(pool) step + /// (cppcache/src/SerializationRegistry.cpp:545): the send + /// API lives on the DM half of the pool, not the public + /// surface, so we cast through. + /// + /// + /// Defaults attemptFailover=true, isBackgroundThread=false + /// match the cppcache call site (no overrides in + /// ThinClientPoolDM::GetPDXIdForType). + /// + private static async ValueTask SendSyncRequestAsync( + IPool? pool, + TcrMessage request, + CancellationToken ct) + { + if (pool is null) + { + throw new GeodeException( + "GET_PDX_ID_FOR_TYPE: no pool on the DataOutput context — " + + "PDX serialise needs a pool. Mirror of cppcache " + + "SerializationRegistry.cpp:551 IllegalStateException."); + } + + // Cast to the DM half. IPool's only production impl is + // ThinClientPoolDM, which multi-inherits ThinClientBaseDM where + // SendSyncRequestAsync is declared. Same dispatch shape cppcache + // uses (dynamic_cast). If a test double / future alt-pool + // doesn't derive ThinClientBaseDM, fail loud rather than silently + // dropping the wire op. + if (pool is not ThinClientBaseDM dm) + { + throw new GeodeException( + $"GET_PDX_ID_FOR_TYPE: pool {pool.GetType().Name} is not " + + "ThinClientBaseDM-derived; cannot route the wire op. " + + "Mirror of cppcache SerializationRegistry.cpp:547."); + } + + throw new NotImplementedException(); + //return await dm.SendSyncRequestAsync(request, ct: ct); + } + + /// + /// Parse a one-part reply whose payload is a CacheableInt32 + /// (DSCode 57 + 4 BE bytes). Wire shape is small enough that + /// inlining the decode here is cheaper than introducing a DI cycle + /// to reach SerializationRegistry.ReadObjectAsync. + /// + private static ValueTask ParseInt32ReplyAsync(TcrMessage reply, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (reply.Parts.Count < 1) + { + throw new GeodeException($"GET_PDX_ID_FOR_TYPE reply: expected 1 part, got {reply.Parts.Count}."); + } + + var reader = new DataInput(reply.Parts[0].Payload); + var dsCode = reader.ReadByte(); + if (dsCode != DSCode.CacheableInt32) + { + throw new GeodeException($"GET_PDX_ID_FOR_TYPE reply: expected DSCode CacheableInt32 " + + $"({DSCode.CacheableInt32}), got {dsCode}."); + } + + return ValueTask.FromResult(reader.ReadInt32()); + } + + /// Short string from a MessageType.Exception reply for diagnostics. + private static string DecodeExceptionPreview(TcrMessage reply) + { + throw new NotImplementedException(); + // TcrMessageHelper.DecodeExceptionPreview(reply); + } + + /// Look up cached schema by typeId; on miss. + public PdxType? GetPdxType(int typeId) => + _byTypeId.TryGetValue(typeId, out var t) ? t : null; + + /// + /// Look up unread-field bytes preserved from a previous deserialize. + /// Returns when the object has no preserved data + /// (the common case for a freshly constructed user object). Mirror of + /// cppcache PdxTypeRegistry::getPreserveData + /// (PdxTypeRegistry.cpp:202). + /// + /// + /// NIE until the read side calls SetPreserveData; once that path + /// exists, this lookup just queries the preserved-data map. + /// + public PdxRemotePreservedData? GetPreserveData(object value) => + throw new NotImplementedException( + $"{nameof(PdxTypeRegistry)}.{nameof(GetPreserveData)}: " + + $"preserve-data tracking not yet wired (Phase 2.1 Step B.1 prereq;" + + $" needs SetPreserveData on the read side first)."); + +} diff --git a/src/Geode.Client/Protocol/Serialization/PdxWriterWithTypeCollector.cs b/src/Geode.Client/Protocol/Serialization/PdxWriterWithTypeCollector.cs new file mode 100644 index 0000000..deacc74 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/PdxWriterWithTypeCollector.cs @@ -0,0 +1,15 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// Encodes a PDX object while simultaneously collecting its field layout into +/// a fresh PdxType (used on first serialization of an unknown type). +/// Mirror of cppcache PdxWriterWithTypeCollector +/// (cppcache/src/PdxWriterWithTypeCollector.hpp). +/// +internal sealed class PdxWriterWithTypeCollector(IServiceProvider serviceProvider, string className) + : PdxLocalWriter(serviceProvider) +{ + public string ClassName => className; + + public PdxType GetPdxLocalType() => BuildSchema(className); +} diff --git a/src/Geode.Client/Protocol/Serialization/SerializationRegistry.cs b/src/Geode.Client/Protocol/Serialization/SerializationRegistry.cs new file mode 100644 index 0000000..d014c4f --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/SerializationRegistry.cs @@ -0,0 +1,309 @@ +using System; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol.Serialization; + +internal sealed class SerializationRegistry +{ + private readonly Dictionary _byDsCode = []; + private readonly Dictionary _byType = []; + private readonly ObjectFactory _pdxWriterWithTypeCollectorFactory; + private readonly ObjectFactory _pdxRemoteWriterByClassNameFactory; + private readonly ObjectFactory _pdxRemoteWriterByPdxTypeFactory; + private readonly PdxTypeRegistry _pdxTypeRegistry; + private readonly IServiceProvider _serviceProvider; + private readonly GeodeCache _cache; + private readonly TypeRegistry _typeRegistry; + + public SerializationRegistry( + IServiceProvider serviceProvider, + GeodeCache cache) + { + _serviceProvider = serviceProvider; + _cache = cache; + _typeRegistry = cache.TypeRegistry; + _pdxTypeRegistry = cache.PdxTypeRegistry; + _pdxWriterWithTypeCollectorFactory = ActivatorUtilities.CreateFactory([typeof(string)]); + _pdxRemoteWriterByClassNameFactory = ActivatorUtilities.CreateFactory([typeof(string)]); + _pdxRemoteWriterByPdxTypeFactory = ActivatorUtilities.CreateFactory([typeof(PdxType), typeof(PdxRemotePreservedData)]); + + RegisterBuiltInConverters(); + } + + private void Register(IDataConverter converter) + { + foreach (var dsCode in converter.DsCodes) + { + _byDsCode[dsCode] = converter; + } + _byType[converter.ManagedType] = converter; + } + + private void RegisterBuiltInConverters() + { + // Order: scalar (sorted by DSCode), then bytes, then string, + // then arrays (sorted by DSCode). + // Scalars: no length-prefix on wire — no allocation DoS surface + // — no GeodeCache injection needed. Plain `new ...()` keeps + // these construction sites cheap. + Register(new BooleanDataConverter()); // 53 CacheableBoolean ??bool + Register(new CharacterDataConverter()); // 54 CacheableCharacter ??char + Register(new ByteDataConverter()); // 55 CacheableByte ??byte (unsigned, .NET convention) + Register(new Int16DataConverter()); // 56 CacheableInt16 ??short + Register(new Int32DataConverter()); // 57 CacheableInt32 ??int + Register(new Int64DataConverter()); // 58 CacheableInt64 ??long + Register(new SingleDataConverter()); // 59 CacheableFloat ??float + Register(new DoubleDataConverter()); // 60 CacheableDouble ??double + Register(new DateTimeDataConverter()); // 61 CacheableDate ??DateTime + + // Length-prefixed converters: snapshot + // GeodeCache.CacheProperties.MaxArrayLength / MaxStringLength at + // construction. Pass `_cache` explicitly to ActivatorUtilities — + // GeodeCache isn't DI-registered, only the IServiceProvider is. + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 46 CacheableBytes → byte[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 42/87/88/89 (+69 read-only) → string + + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 26 BooleanArray → bool[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 27 CharArray → char[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 47 CacheableInt16Array → short[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 48 CacheableInt32Array → int[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 49 CacheableInt64Array → long[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 50 CacheableFloatArray → float[] + Register(ActivatorUtilities.CreateInstance(_serviceProvider, _cache)); // 51 CacheableDoubleArray → double[] + // string[] and object[] both take a registry reference so + // each element can re-enter WriteObject / ReadObject with + // its own DSCode. Safe `this` pass ??converter stores the + // reference but doesn't invoke anything on us until Write / + // Read fires post-construction. + Register(new StringArrayDataConverter(this)); // 64 CacheableStringArray ??string[] + Register(new ObjectArrayDataConverter(this)); // 52 CacheableObjectArray ??object[] + + // Tier B-2 collections ??open-generic. Each ManagedType is + // typeof(List<>) / typeof(HashSet<>) / typeof(Dictionary<,>); + // WriteObject's dispatch falls back to + // GetGenericTypeDefinition() so one converter instance handles + // every closed instantiation. Target-shape conversion + // (List ??IList, HashSet ??ISet, + // Dictionary ??Dictionary, ?? happens + // post-decode at TypedResultAdapter, not here. + Register(new LinkedListDataConverter(this)); // 10 CacheableLinkedList ??LinkedList + Register(new ListDataConverter(this)); // 65 CacheableArrayList ??List + Register(new HashSetDataConverter(this)); // 66 CacheableHashSet ??HashSet + Register(new DictionaryDataConverter(this)); // 67 CacheableHashMap ??Dictionary + Register(new StackDataConverter(this)); // 74 CacheableStack ??Stack + } + + + internal int MaxArrayLength => _cache.CacheProperties.MaxArrayLength; + + internal int MaxDepth => _cache.CacheProperties.MaxDepth; + + internal int MaxStringLength => _cache.CacheProperties.MaxStringLength; + + public bool IsRegistered(Type type) + { + ArgumentNullException.ThrowIfNull(type); + if (_byType.ContainsKey(type)) return true; + if (type.IsGenericType && _byType.ContainsKey(type.GetGenericTypeDefinition())) return true; + return false; + } + + /// + /// Async 版本的 。Default 行為跟 sync 相同; + /// converter 自己決定要不要真的 await(用 default interface method 的話 + /// 就是包 sync,override 的話可以真 async)。 + /// + public async ValueTask ReadObjectAsync(DataInput reader, int depth = 0, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(reader); + + if (depth >= MaxDepth) + { + throw new GeodeException( + $"SerializationRegistry: read exceeded MaxDepth ({MaxDepth})."); + } + + var dsCode = reader.ReadByte(); + if (dsCode == DSCode.NullObj) return null; + if (_byDsCode.TryGetValue(dsCode, out var converter)) + { + return await converter.ReadAsync(reader, dsCode, depth, ct); + } + throw new GeodeException($"SerializationRegistry: unknown DSCode {dsCode} on the wire."); + } + + /// + /// Async 版本的 。PDX 路徑() + /// 之後會在這條鏈裡 await wire op(A.4 GetPdxIdForType)。 + /// + public async ValueTask WriteObjectAsync(DataOutput writer, object? value, int depth = 0, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(writer); + + if (depth >= MaxDepth) + { + throw new InvalidOperationException( + $"SerializationRegistry: write exceeded MaxDepth ({MaxDepth})."); + } + + if (value is null) + { + writer.WriteByte(DSCode.NullObj); + return; + } + + var type = value.GetType(); + if (await TryWriteBuiltInAsync(writer, value, type, depth, ct)) return; + if (await TryWritePdxAsync(writer, value, type, ct)) return; + + throw new NotSupportedException($"No SerializationRegistry converter registered for runtime type {type}."); + } + + private async ValueTask TryWriteBuiltInAsync(DataOutput writer, object value, Type type, int depth, CancellationToken ct) + { + if (!_byType.TryGetValue(type, out var converter) && type.IsGenericType) + { + _byType.TryGetValue(type.GetGenericTypeDefinition(), out converter); + } + if (converter is null) return false; + + var dsCode = converter.GetDsCode(value); + writer.WriteByte(dsCode); + await converter.WriteAsync(writer, value, dsCode, depth, ct); + return true; + } + + /// + /// Encode as a PDX wire frame. Returns + /// when isn't registered + /// as PDX (caller falls through to the unsupported-type throw). + /// Mirror of cppcache PdxHelper::serializePdx + /// (PdxHelper.cpp:87). + /// + /// + /// Two branches keyed on whether the className has been collected + /// locally before: + /// + /// Step A (first time): collect schema via + /// , round-trip to + /// server for typeId, cache both, emit frame. + /// Step B (subsequent): reuse the cached schema/typeId + /// via ; ctor form depends on + /// whether the value carries preserved unread fields. + /// + /// + private async ValueTask TryWritePdxAsync(DataOutput writer, object value, Type type, CancellationToken ct) + { + if (!_typeRegistry.TryGetEntry(type, out var entry)) return false; + + var localPdxType = _pdxTypeRegistry.GetLocalPdxType(entry.ClassName); + if (localPdxType is null) + { + using var ptc = _pdxWriterWithTypeCollectorFactory(_serviceProvider, [entry.ClassName]); + entry.Write(value, ptc); + var nType = ptc.GetPdxLocalType(); + nType.Initialize(); + + // A.4 Round-trip to the server to get a cluster-wide typeId. + // pool comes from the DataOutput (mirror cppcache + // DataOutputInternal::getPool). + + // TODO + //nType.TypeId = await _pdxTypeRegistry.GetPdxIdForTypeAsync(entry.ClassName, writer.Pool, nType, true, ct); + + // A.6 Emit the PDX wire frame: DSCode + length + typeId + payload. + // Length covers typeId + payload (cppcache PdxLocalWriter:: + // writePdxHeader convention). + var payload = ptc.BuildPayload(); + writer.WriteByte(DSCode.PDX); + writer.WriteInt32(payload.Length + sizeof(int)); + writer.WriteInt32(nType.TypeId); + writer.WriteBytesOnly(payload); + + // A.7 Cache the schema in both maps so the next call hits + // Step B (no wire op). + _pdxTypeRegistry.AddLocalPdxType(entry.ClassName, nType); + _pdxTypeRegistry.AddPdxType(nType.TypeId, nType); + } + else + { + // Step B — schema cached. cppcache always picks PdxRemoteWriter + // here because WriteUnreadFields is part of the public API; we + // can't tell upfront whether the user will use it. + + // B.1 Look up unread-field bytes preserved from a prior + // deserialize. null is the common case. + var preservedData = _pdxTypeRegistry.GetPreserveData(value); + + // B.2 Two PdxRemoteWriter ctor forms (cppcache PdxHelper.cpp:128). + PdxRemoteWriter prw; + if (preservedData is not null) + { + var mergedPdxType = _pdxTypeRegistry.GetPdxType(preservedData.MergedTypeId) + ?? throw new GeodeException( + $"PdxTypeRegistry: merged typeId {preservedData.MergedTypeId} " + + $"referenced by preserved data is not in the by-typeId cache."); + prw = _pdxRemoteWriterByPdxTypeFactory(_serviceProvider, [mergedPdxType, preservedData]); + } + else + { + prw = _pdxRemoteWriterByClassNameFactory(_serviceProvider, [entry.ClassName]); + } + + using (prw) + { + // B.3 Run user ToData; PdxLocalWriter emits the field + // bytes against the existing schema. + // TODO: cppcache PdxRemoteWriter overrides each WriteXxx + // to splice in preservedData's unread bytes — wire + // that up once WriteUnreadFields lands. + entry.Write(value, prw); + + // B.4 Same frame layout as A.6; typeId picks the merged + // schema when preserved data is present, otherwise the + // local one. + var schema = prw.MergedPdxType ?? localPdxType; + var payload = prw.BuildPayload(); + writer.WriteByte(DSCode.PDX); + writer.WriteInt32(payload.Length + sizeof(int)); + writer.WriteInt32(schema.TypeId); + writer.WriteBytesOnly(payload); + } + } + + return true; + } + + public object? ReadObject(DataInput reader, int depth = 0) + { + ArgumentNullException.ThrowIfNull(reader); + + if (depth >= MaxDepth) + { + throw new GeodeException( + $"SerializationRegistry: read exceeded MaxDepth ({MaxDepth}). " + + "The server payload is more deeply nested than the client " + + "permits ??treat as hostile or buggy unless a legitimate " + + "workload warrants it, in which case tune " + + "GeodeClientOptions.Serialization.MaxDepth."); + } + + var dsCode = reader.ReadByte(); + + if (dsCode == DSCode.NullObj) + { + return null; + } + + // TODO Phase 2+: PDX fall-through ?? // if (dsCode == DSCode.PDX) return ReadPdx(reader); + + if (_byDsCode.TryGetValue(dsCode, out var converter)) + { + return converter.Read(reader, dsCode, depth); + } + + throw new GeodeException($"SerializationRegistry: unknown DSCode {dsCode} on the wire."); + } + +} diff --git a/src/Geode.Client/Protocol/Serialization/SingleArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/SingleArrayDataConverter.cs new file mode 100644 index 0000000..c5f58b1 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/SingleArrayDataConverter.cs @@ -0,0 +1,63 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (50). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by 4 bytes big-endian +/// IEEE-754 per element. Mirrors cppcache CacheableFloatArray +/// (CacheableArrayPrimitive<float, CacheableFloatArray>). +/// +/// +/// Per-element wire shape matches +/// (DSCode 59) ??NaN / ±Infinity round-trip preserves IEEE-754 bit +/// pattern. Same key / null / empty rules as +/// . +/// +internal sealed class SingleArrayDataConverter(GeodeCache cache) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableFloatArray }; + + private readonly int _maxArrayLength + = cache.CacheProperties.MaxArrayLength; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, float[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxArrayLength) + { + throw new InvalidOperationException( + $"SingleArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({_maxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + writer.WriteFloat(element); + } + return ValueTask.CompletedTask; + } + + public override float[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > _maxArrayLength) + { + throw new GeodeException( + $"SingleArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({_maxArrayLength}) ??refusing to allocate."); + } + var array = new float[length]; + for (var i = 0; i < length; i++) + { + array[i] = reader.ReadFloat(); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/SingleDataConverter.cs b/src/Geode.Client/Protocol/Serialization/SingleDataConverter.cs new file mode 100644 index 0000000..c6bd511 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/SingleDataConverter.cs @@ -0,0 +1,33 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??/// (59). Wire payload is 4 bytes +/// IEEE-754 big-endian, no length prefix. Mirrors cppcache +/// CacheableFloat (cppcache/src/CacheableBuiltins.cpp +/// toData / fromData). +/// +/// +/// NaN / ±??wire shapes are JVM-identical (Java +/// Float.floatToRawIntBits ??.NET +/// ), so no +/// special handling needed for those payloads. Using float as +/// a region Key compiles (it implements +/// ) but is impractical: NaN keys +/// can never be found again (NaN ??NaN under IEEE-754) and ±0 +/// collide. Use integral keys when possible. +/// +internal sealed class SingleDataConverter : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableFloat }; + + public override byte[] DsCodes => _dsCodes; + + public override ValueTask WriteAsync(DataOutput writer, float value, byte dsCode, int depth, CancellationToken ct) + { + writer.WriteFloat(value); + return ValueTask.CompletedTask; + } + + public override float Read(DataInput reader, byte dsCode, int depth) => + reader.ReadFloat(); +} diff --git a/src/Geode.Client/Protocol/Serialization/StackDataConverter.cs b/src/Geode.Client/Protocol/Serialization/StackDataConverter.cs new file mode 100644 index 0000000..59cbfee --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/StackDataConverter.cs @@ -0,0 +1,105 @@ +using System.Collections; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for Stack<T> ??/// (74). Wire payload is the +/// standard collection shape ??VL-encoded length followed by N +/// fully-serialised elements in bottom-to-top order (matching +/// Java Stack/Vector's elementData[0..N-1] / +/// cppcache's std::vector backing). Mirrors +/// clicache/src/CacheableStack.cpp::ToData which writes via +/// Linq::Enumerable::Reverse(stack). +/// +/// +/// +/// The order footgun. Stack<T> in .NET enumerates +/// top?�bottom (most recently pushed first); the wire expects +/// bottom?�top. Write reverses, read does not. Symmetric. +/// Round-trip preserves the original push order ??Push(A); Push(B); +/// Push(C) writes wire [A, B, C], read pushes in wire order +/// so the rebuilt stack has C on top exactly as the original. +/// +/// +/// Open-generic registration. returns +/// typeof(Stack<>); the registry's WriteObject +/// dispatch falls back to +/// when the closed-type lookup misses, so this single instance handles +/// every closed Stack<T>. +/// +/// +/// Read returns canonical Stack<object?>. +/// Target-shape conversion (to Stack<int>) happens at +/// 's Stack<> branch, +/// which has to re-reverse the canonical's top?�bottom +/// iteration before constructing the typed Stack<T> +/// via its IEnumerable<T> ctor (push-in-iteration-order +/// semantics). +/// +/// +internal sealed class StackDataConverter : IDataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableStack }; + + private readonly SerializationRegistry _registry; + + public StackDataConverter(SerializationRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public byte[] DsCodes => _dsCodes; + + public Type ManagedType => typeof(Stack<>); + + public byte GetDsCode(object value) => DSCode.CacheableStack; + + public async ValueTask WriteAsync(DataOutput writer, object value, byte dsCode, int depth, CancellationToken ct) + { + var source = (ICollection)value; + if (source.Count > _registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"StackDataConverter: cannot serialise a stack of {source.Count} elements " + + $"— exceeds Serialization.MaxArrayLength ({_registry.MaxArrayLength})."); + } + writer.WriteArrayLen(source.Count); + + var buffer = new object?[source.Count]; + var idx = source.Count - 1; + foreach (var item in source) + { + buffer[idx--] = item; + } + foreach (var item in buffer) + { + await _registry.WriteObjectAsync(writer, item, depth + 1, ct); + } + } + + public object? Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + var stack = new Stack(); + if (length <= 0) + { + return stack; + } + if (length > _registry.MaxArrayLength) + { + throw new GeodeException( + $"StackDataConverter: wire stack length {length} exceeds " + + $"Serialization.MaxArrayLength ({_registry.MaxArrayLength}) ??refusing to allocate."); + } + + // Wire is bottom?�top order; pushing in wire order places + // wire[0] at the bottom and wire[N-1] on top ??original + // push sequence preserved. + for (var i = 0; i < length; i++) + { + stack.Push(_registry.ReadObject(reader, depth + 1)); + } + return stack; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/StringArrayDataConverter.cs b/src/Geode.Client/Protocol/Serialization/StringArrayDataConverter.cs new file mode 100644 index 0000000..a072d90 --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/StringArrayDataConverter.cs @@ -0,0 +1,110 @@ +namespace Geode.Client.Protocol.Serialization; + +/// +/// for [] ??/// (64). Wire payload is a +/// VL-encoded length (1 / 3 / 5 bytes) followed by N +/// fully-serialised objects ??each element starts with its +/// own DSCode byte (42 / 87 / 88 / 89 for the four string variants, +/// or 41 for null elements). Mirrors cppcache +/// CacheableStringArray +/// (CacheableArrayPrimitive<shared_ptr<CacheableString>, +/// CacheableStringArray>) which routes through +/// serializer::writeArrayObject ??writeObject(shared_ptr) +/// per element (the shared_ptr overload writes DSCode + +/// payload via the registry, NOT a raw string body). +/// +/// +/// +/// Different from the primitive array converters. The +/// bool[] / int[] / ??paths write raw element bytes +/// with no per-element DSCode (the array's DSCode 26 / 48 / ??fully +/// specifies the element shape). For [] +/// the per-element shape is ambiguous (ASCII short vs modified-UTF-8 +/// vs UTF-16 huge), so cppcache + Java write the full DSCode + +/// payload per element. We delegate to +/// / +/// so the choice +/// matches exactly element by +/// element. +/// +/// +/// Why the registry reference: writing one element needs the +/// same encode dispatch that a top-level Put uses ??pick a +/// DSCode (42 / 87 / 88 / 89), emit it, write the body. Reading +/// needs the symmetric path. Passing the registry through the +/// constructor keeps this converter unaware of StringDataConverter +/// directly and makes adding non-ASCII / huge elements transparent. +/// +/// +/// null elements survive the round trip: writer hits the +/// registry's WriteObject(value: null) branch and emits DSCode +/// 41 (); reader sees 41 and returns +/// null into the result array slot. A top-level null +/// [] (the whole array is null) is +/// intercepted by the registry one level higher and never reaches +/// this converter. +/// +/// +/// +/// Takes the owning so each +/// element can re-enter +/// /. The +/// this-reference at registry-construction time is safe: +/// we only store it and call it later from / +/// , by which point the registry is fully +/// populated. +/// +internal sealed class StringArrayDataConverter(SerializationRegistry registry) + : DataConverter +{ + private static readonly byte[] _dsCodes = { DSCode.CacheableStringArray }; + + public override byte[] DsCodes => _dsCodes; + + public override async ValueTask WriteAsync(DataOutput writer, string[] value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > registry.MaxArrayLength) + { + throw new InvalidOperationException( + $"StringArrayDataConverter: cannot serialise an array of {value.Length} elements " + + $"— exceeds Serialization.MaxArrayLength ({registry.MaxArrayLength})."); + } + writer.WriteArrayLen(value.Length); + foreach (var element in value) + { + await registry.WriteObjectAsync(writer, element, depth + 1, ct); + } + } + + public override string[] Read(DataInput reader, byte dsCode, int depth) + { + var length = reader.ReadArrayLen(); + if (length <= 0) + { + return Array.Empty(); + } + if (length > registry.MaxArrayLength) + { + throw new GeodeException( + $"StringArrayDataConverter: wire array length {length} exceeds " + + $"Serialization.MaxArrayLength ({registry.MaxArrayLength}) ??refusing to allocate."); + } + // Element type is string?[] in spirit (nulls survive), but the + // CLR Type is the same string[] either way ??nullable + // annotations aren't part of runtime type identity, so the + // registry's _byType lookup hits this converter for both + // string[] and string?[] uses on the consumer side. + var array = new string[length]; + for (var i = 0; i < length; i++) + { + // Cast is safe: the wire DSCode dispatch on the read + // side will either return a string (from StringDataConverter) + // or null (NullObj=41 handled by the registry). Anything + // else means corrupt wire ??let InvalidCastException + // surface that as a hard fault rather than silently + // produce wrong data. + array[i] = (string)registry.ReadObject(reader, depth + 1)!; + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/Serialization/StringDataConverter.cs b/src/Geode.Client/Protocol/Serialization/StringDataConverter.cs new file mode 100644 index 0000000..5d621ae --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/StringDataConverter.cs @@ -0,0 +1,276 @@ +using Geode.Client.Internal; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// for ??four +/// wire DSCodes plus the null-string sentinel. Mirrors cppcache +/// CacheableString (cppcache/src/CacheableString.cpp) +/// and the dispatch logic in +/// DataOutput::writeString (cppcache/include/geode/DataOutput.hpp:273). +/// +/// +/// +/// Four encode forms, one converter ??the only Tier A +/// converter that returns different DSCodes for different values. +/// The choice is made by after a single +/// content scan; branches on the chosen DSCode +/// without rescanning. +/// +/// +/// +/// DSCodeEncode form +/// +/// +/// 87 CacheableASCIIString +/// u16 char-count + ASCII bytes. Picked when every +/// char is in 0x01..0x7F and char count ??65535. +/// +/// +/// 88 CacheableASCIIStringHuge +/// u32 char-count + ASCII bytes. Picked when every +/// char is ASCII but char count exceeds 65535. +/// +/// +/// 42 CacheableString +/// u16 byte-count + Java modified UTF-8 bytes. +/// Picked when content has non-ASCII (or NUL) chars and the +/// encoded byte length fits in u16. +/// +/// +/// 89 CacheableStringHuge +/// u32 char-count + UTF-16 BE chars. Picked when +/// content has non-ASCII and modified-UTF-8 byte length would +/// exceed 65535. This DSCode does NOT use modified UTF-8 +/// ??it switches to UTF-16 BE because the length prefix unit +/// also changes from "bytes" to "chars". Matches cppcache +/// writeUtf16Huge. +/// +/// +/// 69 CacheableNullString +/// No payload. cppcache writes this for null in +/// known-type-string slots; we never produce it on write +/// (registry intercepts null with +/// before reaching this converter) but accept it on read for +/// server-compat. +/// +/// +/// +/// Modified UTF-8 vs standard UTF-8: NUL is encoded as +/// 0xC0 0x80 (2 bytes, not 1); supplementary code points +/// arrive as a surrogate pair of two 3-byte sequences (6 bytes +/// total) rather than the 4-byte UTF-8 form. We cannot reuse +/// ??hand-rolled in +/// / +/// . +/// +/// +internal sealed class StringDataConverter(GeodeCache cache) + : DataConverter +{ + // 87/88/42/89 cover the four encode forms; 69 is read-only + // tolerance for null-string sentinels coming from the server. + private static readonly byte[] _dsCodes = + { + DSCode.CacheableASCIIString, // 87 + DSCode.CacheableASCIIStringHuge, // 88 + DSCode.CacheableString, // 42 + DSCode.CacheableStringHuge, // 89 + DSCode.CacheableNullString, // 69 ??decode-only + }; + + /// + /// Snapshot of . + /// Unit is whichever length the chosen DSCode encodes (chars for + /// 87 / 88 / 89, modified-UTF-8 bytes for 42); same numeric cap + /// applies to all four for simplicity. Snapshotted at ctor for + /// the same reason as the array converters' _maxArrayLength. + /// + private readonly int _maxStringLength + = cache.CacheProperties.MaxStringLength; + + public override byte[] DsCodes => _dsCodes; + + /// + /// Pick which of the four encode DSCodes to emit for + /// . Algorithm matches cppcache + /// DataOutput::writeString: count chars, add per-char + /// extra bytes for non-ASCII, then dispatch on (isAscii ? isHuge). + /// + public override byte GetDsCode(string value) + { + var charLen = value.Length; + var utfLen = charLen; + foreach (var c in value) + { + if (c >= 0x0001 && c <= 0x007F) + { + // 1-byte ASCII path ??already counted by charLen. + } + else if (c > 0x07FF) + { + // 3-byte modified-UTF-8 char. + utfLen += 2; + } + else + { + // 2-byte modified-UTF-8 char (covers NUL via 0xC0 0x80 + // and the 0x80..0x7FF range). + utfLen += 1; + } + } + + var isAscii = (utfLen == charLen); + if (!isAscii) + { + return utfLen > 0xFFFF + ? DSCode.CacheableStringHuge // 89 ??UTF-16 BE + : DSCode.CacheableString; // 42 ??mod UTF-8 + } + return charLen > 0xFFFF + ? DSCode.CacheableASCIIStringHuge // 88 ??ASCII huge + : DSCode.CacheableASCIIString; // 87 ??ASCII short + } + + public override ValueTask WriteAsync(DataOutput writer, string value, byte dsCode, int depth, CancellationToken ct) + { + if (value.Length > _maxStringLength) + { + throw new InvalidOperationException( + $"StringDataConverter: cannot serialise a string of {value.Length} chars " + + $"— exceeds Serialization.MaxStringLength ({_maxStringLength})."); + } + switch (dsCode) + { + case DSCode.CacheableASCIIString: + writer.WriteUInt16((ushort)value.Length); + WriteAsciiBytes(writer, value); + break; + + case DSCode.CacheableASCIIStringHuge: + writer.WriteInt32(value.Length); + WriteAsciiBytes(writer, value); + break; + + case DSCode.CacheableString: + writer.WriteJavaModifiedUtf8(value); + break; + + case DSCode.CacheableStringHuge: + writer.WriteInt32(value.Length); + foreach (var c in value) + { + writer.WriteUInt16(c); + } + break; + + default: + throw new ArgumentOutOfRangeException( + nameof(dsCode), + dsCode, + $"StringDataConverter cannot write payload for DSCode {dsCode}; " + + $"GetDsCode only emits 42 / 87 / 88 / 89."); + } + return ValueTask.CompletedTask; + } + + public override string? Read(DataInput reader, byte dsCode, int depth) + { + switch (dsCode) + { + case DSCode.CacheableASCIIString: + { + // u16 length is wire-bounded to 65535 (already a + // ~130KB allocation max). Still apply MaxStringLength + // so a user who tightened the cap to e.g. 100 sees + // it honoured on every variant. + int length = reader.ReadUInt16(); + EnsureStringLength(length); + return ReadAsciiBytes(reader, length); + } + + case DSCode.CacheableASCIIStringHuge: + { + // i32 length is the primary attack surface ??can be + // pinned at int.MaxValue by a hostile server. + int length = reader.ReadInt32(); + EnsureStringLength(length); + return ReadAsciiBytes(reader, length); + } + + case DSCode.CacheableString: + // u16 byte-length is wire-bounded to 65535 ??at most + // a ~130KB char[] inside ReadJavaModifiedUtf8. Below + // any reasonable MaxStringLength so we skip the check + // here rather than refactor ReadJavaModifiedUtf8 to + // surface its internal length. + return reader.ReadJavaModifiedUtf8(); + + case DSCode.CacheableStringHuge: + { + int charCount = reader.ReadInt32(); + if (charCount == 0) return string.Empty; + EnsureStringLength(charCount); + var chars = new char[charCount]; + for (var i = 0; i < charCount; i++) + { + chars[i] = (char)reader.ReadUInt16(); + } + return new string(chars); + } + + case DSCode.CacheableNullString: + // cppcache typed-string-slot null sentinel. Registry + // produces 41 (NullObj) for nulls; we tolerate 69 on + // read for server-side compat. + return null; + + default: + throw new ArgumentOutOfRangeException( + nameof(dsCode), + dsCode, + $"StringDataConverter cannot read payload for DSCode {dsCode}."); + } + } + + private void EnsureStringLength(int length) + { + if (length > _maxStringLength) + { + throw new GeodeException( + $"StringDataConverter: wire string length {length} exceeds " + + $"Serialization.MaxStringLength ({_maxStringLength}) ??refusing to allocate."); + } + } + + /// + /// Write the body of an ASCII-encoded string ??one byte per + /// char, no length prefix (caller has already written it). + /// + private static void WriteAsciiBytes(DataOutput writer, string value) + { + // The cppcache path masks each char with 0x7F ("blindly assumes + // ASCII"); GetDsCode already verified every char is in + // 0x01..0x7F before picking an ASCII DSCode, so no masking is + // needed ??the cast is exact. + foreach (var c in value) + { + writer.WriteByte((byte)c); + } + } + + /// + /// Read ASCII bytes as a string. Each + /// byte becomes one via direct widening. + /// + private static string ReadAsciiBytes(DataInput reader, int count) + { + if (count == 0) return string.Empty; + var chars = new char[count]; + for (var i = 0; i < count; i++) + { + chars[i] = (char)reader.ReadByte(); + } + return new string(chars); + } +} diff --git a/src/Geode.Client/Protocol/Serialization/TypedResultAdapter.cs b/src/Geode.Client/Protocol/Serialization/TypedResultAdapter.cs new file mode 100644 index 0000000..8ac52fc --- /dev/null +++ b/src/Geode.Client/Protocol/Serialization/TypedResultAdapter.cs @@ -0,0 +1,355 @@ +using System.Collections; + +namespace Geode.Client.Protocol.Serialization; + +/// +/// Top-edge adapter that reshapes a canonical-form value produced by +/// into the strongly-typed +/// shape the caller declared on . Used +/// by at the boundary +/// between the object-typed internal pipeline and the typed public +/// surface. +/// +/// +/// +/// Lifecycle. Registered as DI Scoped alongside +/// so the two collaborators share a +/// cache scope. Stateless today, but Scoped leaves room for per-cache +/// reflection caches (compiled element-adders, type-walker delegates) +/// and per-cache PDX type rules without revisiting the lifetime later. +/// +/// +/// Why this exists. Java's wire format for ArrayList / HashMap / +/// HashSet does not encode the container element type — each slot is +/// tagged by per-element DSCode only — so +/// always returns the +/// erased canonical form (List<object?> for +/// ). The caller may declare the +/// region as IRegion<int, IList<int>>; this adapter +/// reshapes List<object?> into List<int> via +/// recursive descent, so nested cases like +/// IList<IList<string>> work without per-call +/// reflection plumbing at the call site. +/// +/// +/// Two-pass cost. The wire-decode pass produces a canonical +/// tree; this adapter walks that tree a second time. Every level is +/// touched twice. Tolerable at MVP data scale; if profiling exposes +/// the cost the alternative is plumbing a +/// hint down through so +/// the converter materialises target-shaped in one pass. That retrofit +/// would not break public surface — only +/// callers would shift internally. +/// +/// +/// Null handling. null input becomes default(T): +/// reference types fall through as null, value types collapse to +/// their zero value (0, false, etc.). Matches the .NET +/// convention for "no value" returns in dictionary-style APIs. +/// +/// +/// Early out. Scalars (int, string) and primitive +/// arrays (int[], string[]) come back from the registry +/// already in their concrete CLR type, so the +/// check returns the input unchanged with zero allocation. +/// +/// +internal sealed class TypedResultAdapter +{ + /// + /// Typed entry — convert to the + /// shape. null input yields + /// default(T). + /// + public T? Convert(object? raw) + { + if (raw is null) + { + return default; + } + // The non-generic worker has already produced a value + // assignable to typeof(T); the cast unboxes value types and + // is a reference cast otherwise. + return (T?)Convert(raw, typeof(T)); + } + + /// + /// Reflection entry — convert to a value + /// assignable to . Recurses into + /// generic container element types. + /// + /// + /// 's shape cannot be reshaped to + /// (e.g. raw is a string and + /// the target is int[]), or is + /// a generic family the adapter does not yet know about (Dictionary + /// / HashSet land in follow-up PRs). + /// + public object? Convert(object? raw, Type targetType) + { + if (raw is null) + { + return null; + } + + // Scalars and primitive arrays already arrive typed from the + // registry — int, string, bool[], string[], … + if (targetType.IsInstanceOfType(raw)) + { + return raw; + } + + if (targetType.IsGenericType) + { + var def = targetType.GetGenericTypeDefinition(); + + // IList / List / IEnumerable / ICollection / + // IReadOnlyList / IReadOnlyCollection — all + // assignable from List, so a single materialisation + // covers them. + if (def == typeof(IList<>) || def == typeof(List<>) + || def == typeof(IEnumerable<>) || def == typeof(ICollection<>) + || def == typeof(IReadOnlyList<>) || def == typeof(IReadOnlyCollection<>)) + { + return ConvertToList(raw, targetType.GetGenericArguments()[0]); + } + + // ISet / HashSet / IReadOnlySet — all assignable + // from HashSet; canonical raw is HashSet from + // HashSetDataConverter. + if (def == typeof(ISet<>) || def == typeof(HashSet<>) + || def == typeof(IReadOnlySet<>)) + { + return ConvertToHashSet(raw, targetType.GetGenericArguments()[0]); + } + + // IDictionary / Dictionary / + // IReadOnlyDictionary — all assignable from + // Dictionary; canonical raw is + // Dictionary from DictionaryDataConverter. + if (def == typeof(IDictionary<,>) || def == typeof(Dictionary<,>) + || def == typeof(IReadOnlyDictionary<,>)) + { + var args = targetType.GetGenericArguments(); + return ConvertToDictionary(raw, args[0], args[1]); + } + + // LinkedList — own branch because LinkedList does + // NOT implement IList; it's a peer of HashSet on + // the .NET collection-interface lattice. + if (def == typeof(LinkedList<>)) + { + return ConvertToLinkedList(raw, targetType.GetGenericArguments()[0]); + } + + // Stack — own branch because the canonical + // Stack iterates top→bottom and Stack's + // IEnumerable ctor pushes in iteration order, so a naïve + // pass-through would invert the stack. See ConvertToStack + // for the reverse step. + if (def == typeof(Stack<>)) + { + return ConvertToStack(raw, targetType.GetGenericArguments()[0]); + } + + // Follow-up PRs: + // Queue + // SortedSet, SortedDictionary<,> + + throw new InvalidCastException( + $"TypedResultAdapter has no rule for generic target {targetType}."); + } + + if (targetType.IsArray) + { + return ConvertToArray(raw, targetType.GetElementType()!); + } + + throw new InvalidCastException( + $"TypedResultAdapter cannot convert {raw.GetType()} to {targetType}."); + } + + /// + /// Build a List<> from + /// any enumerable , recursing per element so + /// nested generics (IList<IList<string>>) line + /// up. + /// + private object ConvertToList(object raw, Type elementType) + { + if (raw is not IEnumerable source) + { + throw new InvalidCastException( + $"TypedResultAdapter: expected an enumerable to materialise a list, got {raw.GetType()}."); + } + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + foreach (var item in source) + { + list.Add(Convert(item, elementType)); + } + return list; + } + + /// + /// Build a HashSet<> + /// from any enumerable . Materialises the + /// converted elements into a typed List<elementType> + /// first, then constructs the set from HashSet<T>'s + /// IEnumerable<T> constructor — keeps the recursive + /// element conversion path identical to the list branch and avoids + /// reflecting on HashSet<T>.Add. + /// + private object ConvertToHashSet(object raw, Type elementType) + { + if (raw is not IEnumerable source) + { + throw new InvalidCastException( + $"TypedResultAdapter: expected an enumerable to materialise a set, got {raw.GetType()}."); + } + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + foreach (var item in source) + { + list.Add(Convert(item, elementType)); + } + + // HashSet(IEnumerable) ctor — picked over CreateInstance + // + reflective Add so element conversion stays uniform with + // ConvertToList. + var setType = typeof(HashSet<>).MakeGenericType(elementType); + return Activator.CreateInstance(setType, list)!; + } + + /// + /// Build a + /// Dictionary<,> + /// from any non-generic + /// . Each entry's key and value run through + /// independently so nested + /// generics on either side line up. + /// + private object ConvertToDictionary(object raw, Type keyType, Type valueType) + { + if (raw is not IDictionary source) + { + throw new InvalidCastException( + $"TypedResultAdapter: expected a dictionary to materialise a map, got {raw.GetType()}."); + } + + var dictType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType); + var dict = (IDictionary)Activator.CreateInstance(dictType, source.Count)!; + foreach (DictionaryEntry entry in source) + { + if (entry.Key is null) + { + // DictionaryDataConverter.Read already filters this on + // the way in, but a non-wire-origin raw (e.g. a unit + // test feeding a hand-built dictionary) could still + // carry one. Surface the same shape of failure. + throw new InvalidCastException( + "TypedResultAdapter: source dictionary contains a null key; " + + "Dictionary does not permit null keys."); + } + + dict.Add( + Convert(entry.Key, keyType)!, + Convert(entry.Value, valueType)); + } + return dict; + } + + /// + /// Build a LinkedList<> + /// from any enumerable . Same recipe as + /// : materialise typed elements into + /// a scratch List<elementType> first, then construct + /// the linked list from its + /// IEnumerable<T> ctor — preserves source iteration + /// order, which for canonical LinkedList<object?> + /// from the wire is head→tail. + /// + private object ConvertToLinkedList(object raw, Type elementType) + { + if (raw is not IEnumerable source) + { + throw new InvalidCastException( + $"TypedResultAdapter: expected an enumerable to materialise a linked list, got {raw.GetType()}."); + } + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + foreach (var item in source) + { + list.Add(Convert(item, elementType)); + } + + // LinkedList(IEnumerable) ctor appends each element via + // AddLast — source iteration order becomes head→tail. + var llType = typeof(LinkedList<>).MakeGenericType(elementType); + return Activator.CreateInstance(llType, list)!; + } + + /// + /// Build a Stack<> from + /// any enumerable . Source iteration is + /// assumed top→bottom (canonical Stack<object?> from + /// works that way); the + /// helper reverses before constructing the typed stack so its + /// IEnumerable<T> ctor (which pushes in iteration + /// order) ends up with the original top still on top. + /// + private object ConvertToStack(object raw, Type elementType) + { + if (raw is not IEnumerable source) + { + throw new InvalidCastException( + $"TypedResultAdapter: expected an enumerable to materialise a stack, got {raw.GetType()}."); + } + + var listType = typeof(List<>).MakeGenericType(elementType); + var list = (IList)Activator.CreateInstance(listType)!; + foreach (var item in source) + { + list.Add(Convert(item, elementType)); + } + + // In-place reverse at the non-generic IList layer — avoids + // reflecting on List.Reverse() while still being O(N). + // After reverse: list[0] is the original bottom, list[N-1] + // is the original top. Stack(IEnumerable) pushes in + // that order, so the rebuilt stack has the original top on + // top. + for (int i = 0, j = list.Count - 1; i < j; i++, j--) + { + (list[i], list[j]) = (list[j], list[i]); + } + + var stackType = typeof(Stack<>).MakeGenericType(elementType); + return Activator.CreateInstance(stackType, list)!; + } + + /// + /// Build a typed of + /// from any collection + /// with a known length. + /// + private object ConvertToArray(object raw, Type elementType) + { + if (raw is not ICollection source) + { + throw new InvalidCastException( + $"TypedResultAdapter: expected a collection to materialise an array, got {raw.GetType()}."); + } + + var array = Array.CreateInstance(elementType, source.Count); + var i = 0; + foreach (var item in source) + { + array.SetValue(Convert(item, elementType), i++); + } + return array; + } +} diff --git a/src/Geode.Client/Protocol/TcrChunkedResult.cs b/src/Geode.Client/Protocol/TcrChunkedResult.cs new file mode 100644 index 0000000..450929a --- /dev/null +++ b/src/Geode.Client/Protocol/TcrChunkedResult.cs @@ -0,0 +1,73 @@ +namespace Geode.Client.Protocol; + +/// +/// Per-request accumulator for the chunks of a chunked TCR reply. +/// Mirrors cppcache TcrChunkedResult +/// (cppcache/src/TcrChunkedContext.hpp:37-114). +/// +/// +/// +/// Some reply types (RemoveAll, PutAll, GetAll70, Query, registerInterest, +/// executeFunction…) come back across multiple chunks rather than as a +/// single . The dispatcher reads each chunk's +/// header to learn the payload length + isLastChunk flag, then +/// hands the body to a result instance the caller registered with the +/// request. The result accumulates whatever state it needs (a list of +/// objects, a count, etc.) and exposes the final value to the caller +/// once the dispatcher signals completion. +/// +/// +/// What we drop from cppcache. +/// +/// +/// finalize / waitFinalize / +/// binary_semaphore — cppcache uses a semaphore to +/// shuttle control back to the calling thread after a worker +/// thread finishes draining chunks. .NET / +/// +/// replaces it; signalling lives on the dispatcher, not on +/// the result. +/// m_ex / setException / getException — +/// cppcache stashes exceptions caught during chunk processing +/// so the worker thread can surface them later. With async/await +/// exceptions bubble out of directly; +/// the dispatcher converts them into a faulted . +/// m_dsmemId / setEndpointMemId — single-hop / +/// PR-metadata plumbing (Phase 4). Add the slot when the +/// metadata-refresh path lands. +/// +/// +/// Lifecycle. One result instance per request. The dispatcher +/// calls before the first chunk of a fresh attempt +/// (clears any partial state from a prior failed try on a different +/// endpoint) and then once per arriving chunk. +/// After the chunk with isLastChunk=true the dispatcher +/// completes the request and discards the result — results are +/// single-use. +/// +/// +internal abstract class TcrChunkedResult +{ + /// + /// Process one chunk of a chunked reply. Called in arrival order; + /// implementations accumulate state across calls. Mirrors cppcache + /// TcrChunkedResult::handleChunk. + /// + /// The chunk's payload bytes (the body that + /// follows the per-chunk header — payload length already consumed + /// by the dispatcher). The memory is owned by the dispatcher; do + /// not retain a reference past the call return. + /// True on the final chunk (cppcache + /// lastChunkBit). The dispatcher considers the reply + /// complete after this call returns. + public abstract void HandleChunk(ReadOnlyMemory payload, bool isLastChunk); + + /// + /// Drop any partial state accumulated by prior + /// calls. Called by the dispatcher + /// before the first chunk of a (re)attempt — e.g., after failing + /// over to a different endpoint. Mirrors cppcache + /// TcrChunkedResult::reset. + /// + public abstract void Reset(); +} diff --git a/src/Geode.Client/Protocol/TcrConnection.cs b/src/Geode.Client/Protocol/TcrConnection.cs new file mode 100644 index 0000000..9d004f3 --- /dev/null +++ b/src/Geode.Client/Protocol/TcrConnection.cs @@ -0,0 +1,835 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using Geode.Client; +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +internal sealed class TcrConnection( + IServiceProvider serviceProvider, + ILogger logger, + TcrEndpoint endpoint, + ThinClientPoolDM pool) + : IAsyncDisposable +{ + readonly TcpClient _tcpClient = new(); + Stream? _stream; + /// + /// Server's subscription-queue role, captured from the handshake reply + /// byte at step 10. Mirrors cppcache hasServerQueue_ ??despite + /// the "has" prefix it's an enum, not a bool: + /// 0 = NON_REDUNDANT_SERVER (no subscription queue) + /// 1 = REDUNDANT_PRIMARY_SERVER (primary HA copy) + /// 2 = REDUNDANT_SECONDARY_SERVER (secondary HA copy) + /// MVP doesn't subscribe, so this is informational; Phase 12+ will + /// branch on it for HA failover. + /// + private byte _hasServerQueue; + /// + /// Number of events currently buffered in the server's subscription + /// queue for this client, captured from handshake step 11. Mirrors + /// cppcache queueSize_. Non-zero only after a reconnect with + /// durable subscriptions ??Phase 12+. Default 0. + /// + private int _queueSize; + + /// + /// Server's member identity (a serialised InternalDistributedMember), + /// captured opaque from handshake step 12. null until the + /// handshake completes. Phase 6 (pool) / 7 (locator) will parse this + /// to attribute connections to the right server. + /// + private byte[]? _serverMember; + + /// + /// Whether the server has delta propagation enabled, captured from + /// handshake step 14. Mirrors cppcache m_deltaEnabledOnServer. + /// MVP doesn't send delta updates; recorded for Phase 12+ delta + /// support so the operation layer can branch on it without redoing + /// the handshake. + /// + private bool _deltaEnabled; + + private long _createdAt = Stopwatch.GetTimestamp(); // creationTime_ (mutable: UpdateCreationTime resets it) + private long _lastAccessed = Stopwatch.GetTimestamp(); // lastAccessed_ + // cppcache TcrConnection.cpp:65-70,98 ??each conn picks its own [-9, +9] + // jitter at construction to spread load-conditioning expiry across the + // pool and avoid synchronised mass-rotation. + private readonly int _expiryTimeVariancePercentage = RandomNumberGenerator.GetInt32(-9, 10); + + /// + /// True when this conn was created via a path that reserved one of + /// 's per-endpoint slots (cppcache + /// connection-pool-size cap). + /// returns the slot via . + /// + internal bool OwnsEndpointSlot { get; set; } + + /// + /// Owning ; ctor-injected. Mirrors + /// cppcache TcrConnection::poolDM_. Each conn belongs to + /// exactly one pool (endpoints are TCCM-shared across pools, conns + /// aren't). Consumed by to route wire-byte + /// stats back into the owning pool's PoolStatistics; handshake + /// bytes therefore land in + /// too (cppcache parity). + /// + internal ThinClientPoolDM PoolDM => pool; + + /// Target server this connection talks to; set via ctor (mirrors cppcache TcrConnection::endpointObj). + internal TcrEndpoint Endpoint => endpoint; + + /// + /// Send a request and read the next framed + /// message from the wire as the reply. The message-level building + /// block on top of / ; + /// every operation (Ping, Put, Get, ?? ultimately composes through + /// here. Mirrors cppcache TcrConnection::sendRequest. + /// + /// + /// Pure request-response: assumes one in-flight request per + /// connection. Doesn't interpret the reply ??callers branch on + /// themselves (e.g. Reply vs + /// Exception). Phase 6 connection-pool dispatch will lift this to be + /// the only public entry point used by the operation layer. + /// + public async Task SendRequestAsync(TcrMessage request, CancellationToken cancellationToken = default) + { + await SendAsync(request.Encode(), cancellationToken).ConfigureAwait(false); + var replyBytes = await ReceiveAsync(cancellationToken).ConfigureAwait(false); + return TcrMessage.Decode(serviceProvider, replyBytes); + } + + /// + /// Write a fully-encoded frame to the wire and flush. + /// + /// + /// Pure transport: the caller (operation layer) is responsible for + /// producing via + /// or equivalent. Mirrors cppcache/src/TcrConnection.cpp::send. + /// Assumes the connection is already open; + /// throws otherwise. + /// + public async Task SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must be called before {nameof(SendAsync)}."); + + logger.LogTrace("TcrConnection sending {ByteCount} bytes", data.Length); + + await stream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task ConnectAsync(string host, int port, + TimeSpan? connectTimeout = null, + CancellationToken cancellationToken = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (connectTimeout is { } budget && budget > TimeSpan.Zero) + { + cts.CancelAfter(budget); + } + + _tcpClient.NoDelay = true; + await _tcpClient.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); + logger.LogDebug("TcrConnection connected to {host}:{port}", host, port); + _stream = _tcpClient.GetStream(); + + await HandshakeAsync(cancellationToken: cts.Token).ConfigureAwait(false); + } + + /// + /// Run the Geode client-to-server handshake on the already-connected + /// stream. Mirrors cppcache/src/TcrConnection.cpp::initTcrConnection + /// (the portion after createConnection) and + /// HandShake.cpp. + /// + async Task HandshakeAsync( + bool isClientNotification = false, + bool isSecondary = false, + CancellationToken cancellationToken = default) + { + if (isSecondary && !isClientNotification) + { + throw new ArgumentException( + $"{nameof(isSecondary)} requires {nameof(isClientNotification)} = true.", + nameof(isSecondary)); + } + + using var hello = ActivatorUtilities.CreateInstance(serviceProvider); + + const byte ClientToServer = 100; + const byte PrimaryServerToClient = 101; + const byte SecondaryServerToClient = 102; + var connectionType = isClientNotification + ? (isSecondary ? SecondaryServerToClient : PrimaryServerToClient) + : ClientToServer; + hello.WriteByte(connectionType); + + ProtocolVersion.Current.WriteTo(hello); + logger.LogTrace("TcrConnection handshake, sending ProtocolVersion ordinal {Ordinal}", + ProtocolVersion.Current.Ordinal); + + const byte ReplyOk = 59; + hello.WriteByte(ReplyOk); + + if (isClientNotification) + { + throw new NotImplementedException( + "Notification-channel handshake (port-set list) is not " + + "implemented; subscription support lands in Phase 12+."); + } + + if (!isClientNotification) + { + const int HandshakeReadTimeoutMillis = int.MaxValue - 10000; + hello.WriteInt32(HandshakeReadTimeoutMillis); + } + + // + // 6. ClientProxyMembershipID ??one DataSerializable object on the wire. + // Java client writes this as `DataSerializer.writeObject(id, out)`; + // server reads it as `ClientProxyMembershipID.readCanonicalized(in)` + // which internally calls `DataSerializer.readObject`. The single + // `writeObject` call expands into FOUR sequential wire pieces: + // + // 6a. FixedIDByte (u8 = 1) ??DataSerializableFixedID byte form + // 6b. DSFid (u8 = 38) ??ClientProxyMembershipId class id + // 6c. identity (varint length + bytes) ??cppcache m_memID; + // opaque blob containing + // a serialised + // InternalDistributedMember + // (hostname, PID, version,?? + // 6d. uniqueId (i32) ??reconnect / sync counter; 1 for fresh client + // + // Constants: cppcache/include/geode/internal/DSCode.hpp:28 + // (FixedIDByte = 1) and DSFixedId.hpp:47 (ClientProxyMembershipId = 38). + // TODO: extract DSCode / DSFid enums once Phase 3+ accumulates values. + // The 6c identity bytes are produced by ClientProxyMembershipIdBuilder + // (mirrors cppcache ClientProxyMembershipIDFactory + initObjectVars). + const byte ClientProxyMembershipIdDsfid = 38; + const int FreshClientUniqueId = 1; + hello.WriteByte(DSCode.FixedIDByte); // 6a + hello.WriteByte(ClientProxyMembershipIdDsfid); // 6b + var membershipIdBuilder = new ClientProxyMembershipIdBuilder(serviceProvider, "");// todo + hello.WriteBytes(membershipIdBuilder.Build()); // 6c (varint length + bytes) + hello.WriteInt32(FreshClientUniqueId); // 6d + + // + // 7. Overrides (byte[] on the Java side, but always length 1 so far). + // Java: `for (byte b : getOverrides()) hdos.writeByte(b)`. + // Server reads ONE byte: `setOverrides(new byte[] { readByte() })`. + // Currently only conflation override is encoded here, sourced + // from GeodeClientOptions.Subscription.ConflateEvents: + // null ??0 (use server default) + // true ??1 (force conflation on) + // false ??2 (force conflation off) + // TODO: keep an eye on Java geode-core widening this array. + hello.WriteByte(MapConflateEvents()); + + // + // 8. Security mode + optional credentials body. + // SECURITY_CREDENTIALS_NONE = 0 ??MVP + // SECURITY_CREDENTIALS_NORMAL = 1 ??Phase 9 auth + // SECURITY_MULTIUSER_NOTIFICATIONCHANNEL = 3 + // (TcrConnection.hpp:49-51 / Java Handshake.java) + // When mode != NONE, Properties body follows immediately. NONE skips it. + const byte SecurityCredentialsNone = 0; + hello.WriteByte(SecurityCredentialsNone); + + // Flush the whole client-hello in one SendAsync. NoDelay is on + // (set in ConnectAsync), so this lands as a single TCP segment; + // the server reads it as one contiguous handshake. + var clientHello = hello.WrittenSpan.ToArray(); + logger.LogTrace("TcrConnection sending client-hello ({byteCount} bytes)", clientHello.Length); + await SendAsync(clientHello, cancellationToken).ConfigureAwait(false); + + const byte ReplyOkServer = 59; + const byte ReplyServerIsLocator = 67; + const byte ReplySslRequired = 21; + var acceptanceCode = (await ReadHandshakeDataAsync(1, cancellationToken) + .ConfigureAwait(false))[0]; + if (acceptanceCode == ReplySslRequired) + { + throw new GeodeException( + "Geode server requires SSL but client connected in plaintext."); + } + if (acceptanceCode == ReplyServerIsLocator) + { + throw new GeodeException( + "Connected port belongs to a Geode locator, not a server. " + + "Use locator-discovery configuration instead of pointing at this address directly."); + } + + _hasServerQueue = (await ReadHandshakeDataAsync(1, cancellationToken) + .ConfigureAwait(false))[0]; + logger.LogTrace("TcrConnection handshake hasServerQueue = {hasServerQueue}", _hasServerQueue); + + var queueSizeBuf = await ReadHandshakeDataAsync(4, cancellationToken) + .ConfigureAwait(false); + _queueSize = BinaryPrimitives.ReadInt32BigEndian(queueSizeBuf); + logger.LogTrace("TcrConnection handshake queueSize = {queueSize}", _queueSize); + + + var serverMemberLen = await ReadHandshakeArrayLenAsync(cancellationToken) + .ConfigureAwait(false); + _serverMember = serverMemberLen > 0 + ? await ReadHandshakeDataAsync(serverMemberLen, cancellationToken).ConfigureAwait(false) + : []; + logger.LogTrace("TcrConnection handshake serverMember = {byteCount} bytes", _serverMember.Length); + + + var messageLenBuf = await ReadHandshakeDataAsync(2, cancellationToken) + .ConfigureAwait(false); + var messageLen = BinaryPrimitives.ReadUInt16BigEndian(messageLenBuf); + var messageBytes = messageLen > 0 + ? await ReadHandshakeDataAsync(messageLen, cancellationToken).ConfigureAwait(false) + : []; + var serverMessage = Encoding.UTF8.GetString(messageBytes); + logger.LogTrace("TcrConnection handshake serverMessage = '{serverMessage}'", serverMessage); + + _deltaEnabled = (await ReadHandshakeDataAsync(1, cancellationToken) + .ConfigureAwait(false))[0] != 0; + logger.LogTrace("TcrConnection handshake deltaEnabled = {deltaEnabled}", _deltaEnabled); + + if (acceptanceCode != ReplyOkServer) + { + var detail = string.IsNullOrEmpty(serverMessage) + ? "(no message)" + : $"\"{serverMessage}\""; + throw new GeodeException( + $"Geode server refused handshake; AcceptanceCode = {acceptanceCode}. Server says: {detail}."); + } + } + + /// + /// Read one framed message from the wire: 17-byte header followed by + /// the MessageLength body bytes the header advertises. + /// + /// + /// The full frame (header + body) as a contiguous byte array, ready to + /// be handed to by the caller. + /// + /// + /// The peer closed the connection before a full frame was received. + /// + /// + /// The header advertises a negative MessageLength. + /// + /// + /// Pure transport: decoding the bytes back into a + /// is the caller's job. Mirrors cppcache/src/TcrConnection.cpp::readMessage. + /// + public async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + var stream = _stream + ?? throw new InvalidOperationException($"{nameof(ConnectAsync)} must be called before {nameof(ReceiveAsync)}."); + + var frame = new byte[TcrMessage.HeaderLength]; + await stream + .ReadExactlyAsync(frame.AsMemory(0, TcrMessage.HeaderLength), cancellationToken) + .ConfigureAwait(false); + + var messageLength = BinaryPrimitives.ReadInt32BigEndian(frame.AsSpan(4, sizeof(int))); + if (messageLength < 0) + { + throw new InvalidDataException( + $"Received header advertises negative MessageLength={messageLength}."); + } + + logger.LogTrace("TcrConnection received header, MessageLength={messageLength}", messageLength); + + // 2. Grow the buffer and read the parts payload, if any. + if (messageLength > 0) + { + Array.Resize(ref frame, TcrMessage.HeaderLength + messageLength); + await stream + .ReadExactlyAsync( + frame.AsMemory(TcrMessage.HeaderLength, messageLength), cancellationToken) + .ConfigureAwait(false); + } + + PoolDM?.RecordReceivedBytes(frame.Length); + + return frame; + } + /// + /// Map the tristate + /// to the wire byte used in the handshake "overrides" field. Mirrors + /// cppcache TcrConnection::getOverrides. + /// + private byte MapConflateEvents() + { + /// todo + return 0; + //_options.Subscription.ConflateEvents switch + //{ + // null => 0, // CONFLATION_DEFAULT ??let the server decide + // true => 1, // CONFLATION_ON + // false => 2, // CONFLATION_OFF + //}; + } + + /// + /// Read exactly bytes from the underlying + /// stream ??the handshake's ad-hoc, non-framed read primitive. Mirrors + /// cppcache TcrConnection::readHandshakeData. + /// + /// + /// + /// already handles partial reads + cancellation, so this helper is just + /// "allocate buffer + read into it" as a single named step that reads + /// well at the call site. + /// + private async Task ReadHandshakeDataAsync( + int byteCount, + CancellationToken cancellationToken) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must be called before reading handshake data."); + + var buf = new byte[byteCount]; + await stream.ReadExactlyAsync(buf, cancellationToken).ConfigureAwait(false); + return buf; + } + + /// + /// Read Geode's variable-length array-length encoding from the stream. + /// Inverse of / + /// cppcache DataInput::readArrayLen: + /// + /// first byte == -1 (0xFF) ??-1 (null sentinel). + /// first byte == -2 (0xFE) ??u16 length follows (3 bytes total). + /// first byte == -3 (0xFD) ??i32 length follows (5 bytes total). + /// otherwise (0??52) ??first byte itself is the length. + /// + /// + private async Task ReadHandshakeArrayLenAsync(CancellationToken cancellationToken) + { + var first = (sbyte)(await ReadHandshakeDataAsync(1, cancellationToken) + .ConfigureAwait(false))[0]; + return first switch + { + -1 => -1, + -2 => BinaryPrimitives.ReadUInt16BigEndian( + await ReadHandshakeDataAsync(2, cancellationToken).ConfigureAwait(false)), + -3 => BinaryPrimitives.ReadInt32BigEndian( + await ReadHandshakeDataAsync(4, cancellationToken).ConfigureAwait(false)), + _ => first, + }; + } + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + _disposed = true; + + // Dispose the stream first so any pending async work (e.g. TLS + // close_notify once Phase 8 swaps in SslStream) gets a chance to + // flush; then drop the underlying socket. _stream is null if + // ConnectAsync was never called. + if (_stream is not null) + { + await _stream.DisposeAsync().ConfigureAwait(false); + } + _tcpClient.Dispose(); + + // Return the per-endpoint slot the pool reserved for this conn + // (set by CreatePoolConnection* paths). Pool-wide _capSlots is + // still released manually by ThinClientPoolDM at each close site ?? // intentional asymmetry while pool-wide accounting stays in the DM. + if (OwnsEndpointSlot) + { + Endpoint?.ReleaseSlot(); + OwnsEndpointSlot = false; + } + } + + /// + /// Reset both the creation clock and the last-access clock. Mirrors + /// cppcache TcrConnection::updateCreationTime() + /// (cppcache/src/TcrConnection.cpp:1222) ??the pool calls this + /// when load-conditioning replacement fails but the conn isn't + /// expired yet, so the same conn isn't immediately re-elected on + /// the next cleanStaleConnections sweep. + /// + public void UpdateCreationTime() + { + var now = Stopwatch.GetTimestamp(); + Volatile.Write(ref _createdAt, now); + Volatile.Write(ref _lastAccessed, now); + } + + /// + /// Has this connection been unused longer than ? + /// Mirrors cppcache TcrConnection::isIdle + /// (cppcache/src/TcrConnection.cpp:1193). + /// + public bool IsIdle(TimeSpan idleTimeout) + { + if (idleTimeout <= TimeSpan.Zero) return false; + var elapsed = Stopwatch.GetElapsedTime(Volatile.Read(ref _lastAccessed)); + return elapsed > idleTimeout; + } + + /// + /// Has this connection lived longer than + /// since it was opened? Mirrors cppcache TcrConnection::hasExpired + /// (cppcache/src/TcrConnection.cpp:1183). + /// + /// + /// Applies the jitter from + /// cppcache (default 0 = exact threshold; non-zero spreads expiry + /// across a pool to avoid synchronised mass-rotation). + /// + public bool HasExpired(TimeSpan loadConditioningInterval) + { + if (loadConditioningInterval <= TimeSpan.Zero) return false; + var jitter = loadConditioningInterval * _expiryTimeVariancePercentage / 100; + var threshold = loadConditioningInterval + jitter; + return Stopwatch.GetElapsedTime(Volatile.Read(ref _createdAt)) > threshold; + } + + /// + /// Polite shutdown: send + /// (18) so the server frees this socket's session immediately, then + /// the underlying transport. Mirrors + /// cppcache TcrConnection::close() + /// (cppcache/src/TcrConnection.cpp:933-951). + /// + /// + /// Tells the server whether to keep this client's subscription queue + /// (Phase 2+ HA / durable client). Phase 1.1 callers always pass + /// false ??we have no subscription state worth preserving. + /// + /// + /// Fire-and-forget: cppcache does not await any reply (the server + /// just closes its side after receiving the frame) and swallows + /// every exception (LOGINFO only) ??by definition this is + /// the destruction path, so a half-dead socket failing the write is + /// not an error worth propagating. + /// + public async Task CloseAsync(bool keepAlive, CancellationToken ct = default) + { + if (_disposed) + { + return; + } + + // Builder is ctor-injected; cppcache pulls it lazily off DataOutput. + + // Use TcrMessageBuilder.Create (direct new) rather than ActivatorUtilities: + // CloseAsync runs on the sp-teardown path, and ActivatorUtilities would + // re-enter the (already disposing) ServiceProvider to resolve other deps, + // throwing ObjectDisposedException. The static factory doesn't query DI. + var builder = TcrMessageBuilder + .Create(serviceProvider, MessageType.CloseConnection) + .AddKeepAlivePart(keepAlive); + var closeMsg = await builder.BuildAsync(ct); + + // 2-second send budget mirrors cppcache TcrConnection.cpp:944 + // (`send(..., std::chrono::seconds(2), false)`). The connection is + // dying anyway ??don't let a slow / half-dead socket hold up shutdown. + using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + sendCts.CancelAfter(TimeSpan.FromSeconds(2)); + + try + { + await SendAsync(closeMsg.Encode(), sendCts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + // cppcache LOGINFO("Close connection message failed with msg: %s") + // (TcrConnection.cpp:947). By definition we're tearing down ??a + // failed write isn't actionable, just informational. Caller's ct + // cancellation flows through but we still dispose below. + logger.LogInformation(ex, "Close connection message failed"); + } + + await DisposeAsync().ConfigureAwait(false); + } + + /// + /// Stamp this connection's last-access time. Mirrors cppcache + /// TcrConnection::touch() + /// (cppcache/src/TcrConnection.cpp:1201) ??pool managers call + /// it on borrow / return so cleanStaleConnections / + /// can distinguish idle conns from active ones. + /// + public void Touch() + => Volatile.Write(ref _lastAccessed, Stopwatch.GetTimestamp()); + + /// + /// Chunked-reply variant of . + /// Sends the request, reads the first-frame header, and loops the + /// chunk-header / chunk-body pair until the flags byte's + /// LAST_CHUNK bit is set, handing each chunk body to + /// . Mirrors cppcache + /// TcrConnection::sendRequestForChunkedResponse → + /// readMessageChunked + /// (cppcache/src/TcrConnection.cpp:755-799). + /// + /// + /// + /// Wire-format differs from the single-message path. The first + /// 17-byte header for a chunked reply is laid out as + /// [msgType i32][numberOfParts i32][txId i32][chunkLength i32][flags u8] + /// (cppcache TcrConnection::readResponseHeader, + /// :810-851), not the + /// [msgType][msgLength][numParts][txId][earlyAck] shape that + /// parses. Subsequent chunk headers are + /// 5 bytes — [chunkLength i32][flags u8] + /// (readChunkHeader, :853-887). The server picks the + /// layout based on the request opcode; the client must read the + /// shape it asked for. + /// + /// + /// Flags byte. Bit 0 () marks the + /// final chunk — loop exit. Bit 1 indicates a trailing secure + /// part (auth); cppcache reads it via + /// readSecureObjectPart inside the result handler. Phase 1.3 + /// no auth = bit 1 always 0. + /// + /// + /// Returned . Carries the header + /// fields ( and + /// ) so callers can branch on + /// RESPONSE / REPLY / EXCEPTION; the + /// list is empty — chunked + /// payload lives in . The + /// numberOfParts header field is discarded (Phase 1.3 doesn't + /// surface it; if a caller ever needs it, the record can grow a + /// NumberOfParts slot). + /// + /// + /// Exception replies. When the first-frame + /// messageType is the + /// loop still runs — cppcache packs the exception payload + /// into chunks just like a normal response. The handler should + /// accumulate / inspect them as needed; this method returns + /// normally with the Exception message type, and the caller throws. + /// (Phase 1.3 callers use the message type alone for the throw + /// path; surfacing the actual exception text from chunk bytes lands + /// when integration tests demand it.) + /// + /// + public async Task SendRequestAsync( + TcrMessage request, + TcrChunkedResult chunkedResult, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(chunkedResult); + + // Send (same path as the single-message overload). + await SendAsync(request.Encode(), cancellationToken).ConfigureAwait(false); + + // First-frame header (different layout from non-chunked path). + var (msgType, numberOfParts, txId, chunkLen, flags) = + await ReadChunkedResponseHeaderAsync(cancellationToken).ConfigureAwait(false); + + logger.LogTrace( + "TcrConnection chunked reply header: type={MsgType}, parts={NumParts}, " + + "txId={TxId}, firstChunkLen={ChunkLen}, flags=0x{Flags:X2}", + msgType, numberOfParts, txId, chunkLen, flags); + + chunkedResult.Reset(); + + // Chunk loop. Read body of advertised length, hand to result, + // peek lastChunk flag ??if not set, pull next 5-byte chunk + // header and repeat. Mirrors cppcache while-processChunk. + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must complete before chunked send."); + + while (true) + { + if (chunkLen < 0) + { + throw new InvalidDataException( + $"Chunk header advertises negative chunkLength={chunkLen}."); + } + + var body = new byte[chunkLen]; + if (chunkLen > 0) + { + await stream + .ReadExactlyAsync(body.AsMemory(0, chunkLen), cancellationToken) + .ConfigureAwait(false); + } + + var isLastChunk = (flags & LastChunkMask) != 0; + chunkedResult.HandleChunk(body, isLastChunk); + + if (isLastChunk) + { + break; + } + + (chunkLen, flags) = await ReadChunkHeaderAsync(cancellationToken).ConfigureAwait(false); + } + + // Synthesise a TcrMessage carrying just the header fields the + // caller branches on. Body is owned by chunkedResult. + return ActivatorUtilities.CreateInstance( + serviceProvider, + (MessageType)msgType, + txId, + (byte)0, + Array.Empty()); + } + + /// + /// lastChunkAndSecurityFlags bit 0 — this is the final + /// chunk in the reply. Mirrors cppcache LAST_CHUNK_MASK + /// (cppcache/src/TcrMessage.cpp). + /// + private const byte LastChunkMask = 0x01; + + /// + /// Read the 17-byte first-frame header for a chunked reply + /// (msgType i32, numberOfParts i32, txId i32, chunkLength i32, + /// flags u8). Mirrors cppcache + /// TcrConnection::readResponseHeader + /// (cppcache/src/TcrConnection.cpp:810-851). + /// + private async Task<(int MsgType, int NumberOfParts, int TxId, int ChunkLen, byte Flags)> + ReadChunkedResponseHeaderAsync(CancellationToken ct) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must complete before chunked send."); + + var buffer = new byte[TcrMessage.HeaderLength]; + await stream + .ReadExactlyAsync(buffer.AsMemory(0, TcrMessage.HeaderLength), ct) + .ConfigureAwait(false); + + return ( + MsgType: BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(0, 4)), + NumberOfParts: BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(4, 4)), + TxId: BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(8, 4)), + ChunkLen: BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(12, 4)), + Flags: buffer[16]); + } + + /// + /// Read a 5-byte continuation chunk header + /// (chunkLength i32, flags u8). Mirrors cppcache + /// TcrConnection::readChunkHeader + /// (cppcache/src/TcrConnection.cpp:853-887). + /// + private async Task<(int ChunkLen, byte Flags)> ReadChunkHeaderAsync(CancellationToken ct) + { + var stream = _stream + ?? throw new InvalidOperationException( + $"{nameof(ConnectAsync)} must complete before chunked send."); + + const int ChunkHeaderLength = 5; + var buffer = new byte[ChunkHeaderLength]; + await stream + .ReadExactlyAsync(buffer.AsMemory(0, ChunkHeaderLength), ct) + .ConfigureAwait(false); + + return ( + ChunkLen: BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(0, 4)), + Flags: buffer[4]); + } +} + +/* +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using Geode.Client.Internal; +using Geode.Client.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Protocol; + +/// +/// One framed TCP connection to a Geode server-cache port (default 40404). +/// Mirrors cppcache/src/TcrConnection.cpp. +/// +internal sealed class TcrConnection( + IServiceProvider serviceProvider, + ILogger logger, + CacheScopeContext scopeContext, + ClientProxyMembershipIdBuilder membershipIdBuilder, + TcrMessageBuilder messageBuilder) + : IAsyncDisposable +{ + + public IServiceProvider ServiceProvider { get; } = serviceProvider; + + + // Read options through the scope context so named registrations route + // to the right cache (plain IOptions always returned the unnamed + // default). Currently consumed by HandshakeAsync step 7 + // (Subscription.ConflateEvents); Phase 6+ pool / TLS / auth code + // will read further fields. + private readonly GeodeClientOptions _options = scopeContext.Options; + + + + +#pragma warning disable CS0169, CS0414, CS0649 // placeholder mirror fields wired up phase by phase + private long _connectionId; // connectionId + private TcrConnectionManager? _connectionManager; // connectionManager_ + // _tcpClient + _stream above cover cppcache `conn_` (Connector). + private ushort _port; // port_ + private object? _chunksProcessSemaphore; // binary_semaphore chunks_process_semaphore_ (??SemaphoreSlim) + + private int _isBeingUsed; // volatile bool isBeingUsed_ (Interlocked 0/1) + private uint _isUsed; // atomic isUsed_ + +#pragma warning restore CS0169, CS0414, CS0649 + + + + + + + + /// + /// Send a (5) and wait for the server's + /// (6). Mirrors cppcache + /// TcrMessagePing. + /// + /// + /// Server returned a other than + /// (e.g. an Exception reply carrying + /// error text in its parts). + /// + public async Task PingAsync(CancellationToken cancellationToken = default) + { + var reply = await SendRequestAsync(messageBuilder.Ping(), cancellationToken).ConfigureAwait(false); + if (reply.MessageType != MessageType.Reply) + { + throw new GeodeException( + $"Expected Reply ({(int)MessageType.Reply}) to Ping, got " + + $"{reply.MessageType} ({(int)reply.MessageType})."); + } + } + + + + +} + +*/ diff --git a/src/Geode.Client/Protocol/TcrMessage.cs b/src/Geode.Client/Protocol/TcrMessage.cs new file mode 100644 index 0000000..e0e03a9 --- /dev/null +++ b/src/Geode.Client/Protocol/TcrMessage.cs @@ -0,0 +1,184 @@ + +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol; + +/// +/// One TCR (Thin-Client Request / Response) message frame on the wire. +/// +/// +/// Wire layout (all multi-byte fields big-endian): +/// +/// offset 0 : i32 MessageType +/// offset 4 : i32 MessageLength // bytes occupied by the Parts (header excluded) +/// offset 8 : i32 NumParts +/// offset 12 : i32 TransactionId +/// offset 16 : u8 EarlyAck // bit-flags (security, retry, ...) +/// offset 17 : Part[NumParts] // each Part = i32 len + u8 isObject + payload +/// +/// +/// Mirrors TcrMessage::writeHeader / +/// TcrMessage::handleByteArrayResponse / +/// TcrMessage::writeMessageLength in +/// cppcache/src/TcrMessage.cpp. +/// +/// +/// Cppcache writes a dummy 0 for MessageLength at encode time +/// and patches offset 4 once the parts are written. We use a two-pass encode +/// instead (parts first to learn their byte length, then header + parts) ??/// simpler given our writer does not expose a seek/patch API. The output +/// bytes are identical. +/// +/// +internal sealed record TcrMessage( + IServiceProvider ServiceProvider, + MessageType MessageType, + int TransactionId, + byte EarlyAck, + IReadOnlyList Parts) +{ + + /// Fixed-size frame header: four i32 fields + one u8. + public const int HeaderLength = 17; + + /// + /// bit set by + /// to flag a resent message; cppcache TcrMessage::updateHeaderForRetry + /// ORs 0x4 into the EarlyAck byte so the server can dedupe. + /// + private const byte IsRetryBit = 0x4; + + /// + /// Return a copy with the retry bit set on , so + /// the server's ClientHealthMonitor can dedupe a resent op + /// against a prior attempt that may have made it through. + /// + /// + /// Mirrors cppcache TcrMessage::updateHeaderForRetry + /// (cppcache/src/TcrMessage.cpp:805-809). cppcache patches the + /// already-encoded byte buffer in place; we return a new record since + /// is immutable and re-encodes on demand. + /// + public TcrMessage UpdateHeaderForRetry() => + this with { EarlyAck = (byte)(EarlyAck | IsRetryBit) }; + + /// Encode this message to a freshly-allocated byte array. + public byte[] Encode() + { + using var partsWriter = ActivatorUtilities.CreateInstance(ServiceProvider); + foreach (var part in Parts) + { + part.Encode(partsWriter); + } + var partsBytes = partsWriter.WrittenSpan; + + using var w = ActivatorUtilities.CreateInstance(ServiceProvider); + w.WriteInt32((int)MessageType); + w.WriteInt32(partsBytes.Length); + w.WriteInt32(Parts.Count); + w.WriteInt32(TransactionId); + w.WriteByte(EarlyAck); + w.WriteBytesOnly(partsBytes); + return w.WrittenSpan.ToArray(); + } + + /// Decode one message from . + /// + /// The frame is malformed (negative NumParts or + /// MessageLength disagrees with the bytes occupied by the parts). + /// + /// + /// The buffer is shorter than the frame claims. + /// + public static TcrMessage Decode(IServiceProvider serviceProvider, ReadOnlyMemory bytes) + { + var reader = new DataInput(bytes); + + var messageType = (MessageType)reader.ReadInt32(); + var messageLength = reader.ReadInt32(); + var numParts = reader.ReadInt32(); + var transactionId = reader.ReadInt32(); + var earlyAck = reader.ReadByte(); + + if (numParts < 0) + { + throw new FormatException( + $"NumParts must be non-negative, got {numParts}."); + } + + var parts = new List(numParts); + var partsStart = reader.Position; + for (var i = 0; i < numParts; i++) + { + parts.Add(TcrPart.Decode(reader)); + } + var partsConsumed = reader.Position - partsStart; + + if (partsConsumed != messageLength) + { + throw new FormatException( + $"Header MessageLength={messageLength} does not match the {partsConsumed} bytes consumed by the parts."); + } + + return ActivatorUtilities.CreateInstance( + serviceProvider, messageType, transactionId, earlyAck, parts); + } + + public bool Equals(TcrMessage? other) + { + if (other is null) return false; + if (MessageType != other.MessageType) return false; + if (TransactionId != other.TransactionId) return false; + if (EarlyAck != other.EarlyAck) return false; + if (Parts.Count != other.Parts.Count) return false; + for (var i = 0; i < Parts.Count; i++) + { + if (!Parts[i].Equals(other.Parts[i])) return false; + } + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(MessageType); + hash.Add(TransactionId); + hash.Add(EarlyAck); + foreach (var part in Parts) + { + hash.Add(part); + } + return hash.ToHashCode(); + } + + ///// + ///// The server-side exception text carried by an + ///// reply (the Java exception's + ///// fully-qualified class name + message), used by the dispatcher to + ///// classify failures (e.g. auth-required retry). + ///// + ///// + ///// Mirrors TcrMessage::getException + ///// (cppcache/src/TcrMessage.cpp:213), which lazily stringifies + ///// m_value (the deserialized exception payload). NIE stub + ///// until exception-reply deserialization lands. + ///// + //public string GetException() => + // throw new NotImplementedException( + // "Phase 3 ??TcrMessage.GetException (exception-reply payload stringify)"); + + ///// + ///// True when is a user-initiated region op + ///// (Put / Get / Query / register-interest / ...) rather than a + ///// framework-internal control frame (PING, PERIODIC_ACK, + ///// CLOSE_CONNECTION, CLIENT_READY, PDX / CQ / metadata fetches, ...). + ///// + ///// + ///// Mirrors TcrMessage::isUserInitiativeOps + ///// (cppcache/src/TcrMessage.cpp:98). Only the multi-user / + ///// security dispatch path consults this predicate ??Phase 3 work, + ///// hence NIE stub until then. + ///// + //public static bool IsUserInitiativeOps(TcrMessage msg) => + // throw new NotImplementedException( + // "Phase 3 ??TcrMessage.IsUserInitiativeOps (auth / multi-user dispatch)"); +} diff --git a/src/Geode.Client/Protocol/TcrMessageBuilder.GetPdxIdForType.cs b/src/Geode.Client/Protocol/TcrMessageBuilder.GetPdxIdForType.cs new file mode 100644 index 0000000..a4da5a0 --- /dev/null +++ b/src/Geode.Client/Protocol/TcrMessageBuilder.GetPdxIdForType.cs @@ -0,0 +1,63 @@ +/* +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol; + +partial class TcrMessageBuilder +{ + /// + /// Build a (93) request frame. + /// Mirror of cppcache TcrMessageGetPdxIdForType ctor + /// (cppcache/src/TcrMessage.cpp:2874); the "send + reply" flow + /// lives in ThinClientPoolDM::GetPDXIdForType + /// (cppcache/src/ThinClientPoolDM.cpp:900). + /// + /// + /// + /// Wire layout — Header (=93, + /// NumParts=1, TransactionId=-1, EarlyAck=0) followed by: + /// + /// + /// # Part IsObject Payload + /// 1 Schema 1 PdxType body bytes (no leading "I'm a PDX" tag — + /// cppcache writeObjectPart(..., callToData=true), + /// i.e. SerializationRegistry::serializeWithoutHeader). + /// The body itself opens with DSCode.DataSerializable (45) + /// + DSCode.Class (43) + "org.apache.geode.pdx.internal.PdxType" + /// — that's PdxType.ToData's own first bytes. + /// + /// + /// Body serialisation is delegated to , + /// which is currently a NIE leaf (cppcache PdxType.cpp:66). + /// Until it lands, the builder itself wires up but any attempt to + /// actually issue the request will surface the inner NIE. + /// + /// + public async ValueTask GetPdxIdForTypeAsync( + PdxType schema, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(schema); + + var parts = new List(1) + { + // Part 1 — schema body, IsObject=1. PdxType.ToData writes its + // own leading DSCode.DataSerializable byte, so partBuilder + // does not prepend one. Mirror of cppcache + // writeObjectPart(..., callToData=true) at TcrMessage.cpp:2880. + await partBuilder.ObjectAsync(w => + { + schema.ToData(w); + return ValueTask.CompletedTask; + }), + }; + + _ = ct; + return ActivatorUtilities.CreateInstance( + _serviceProvider, MessageType.GetPdxIdForType, + MetaTransactionId, (byte)0, parts); + } +} + +*/ \ No newline at end of file diff --git a/src/Geode.Client/Protocol/TcrMessageBuilder.cs b/src/Geode.Client/Protocol/TcrMessageBuilder.cs new file mode 100644 index 0000000..512ca1b --- /dev/null +++ b/src/Geode.Client/Protocol/TcrMessageBuilder.cs @@ -0,0 +1,153 @@ +using System.Collections.ObjectModel; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol; + +/// +/// Factory for the TCR request frames () each +/// Geode operation puts on the wire. +/// +/// +/// +/// One method per , each in its own partial +/// file (TcrMessageBuilder.Ping.cs, TcrMessageBuilder.Put.cs, +/// ...). The collection mirrors cppcache's TcrMessage.hpp family +/// of TcrMessage* subclasses (TcrMessagePing, +/// TcrMessagePut, TcrMessageRequest, ...) ??same +/// per-operation recipe, expressed as functions returning an immutable +/// rather than as a class hierarchy. +/// +/// +/// Pure functions: no I/O, no hidden state. The "send it + handle the +/// reply" half lives on as op methods +/// ( etc.); callers can also +/// compose with a builder +/// result directly when they want full control over reply dispatch. +/// +/// +/// All MessageTypes use = -1 unless they +/// participate in a Geode transaction (Phase 11+). Mirrors cppcache +/// TcrMessage::writeHeader: m_txId = -1 when no +/// TxState is present. +/// +/// +internal sealed partial class TcrMessageBuilder +{ + readonly IServiceProvider _serviceProvider; + readonly MessageType _messageType; + int _transactionId = -1; + byte _earlyAck = 0; + readonly List _tcrPartBuilders = []; + + private TcrMessageBuilder(IServiceProvider serviceProvider, MessageType messageType) + { + _serviceProvider = serviceProvider; + _messageType = messageType; + } + + public static TcrMessageBuilder Create(IServiceProvider serviceProvider, MessageType messageType) + { + return new TcrMessageBuilder(serviceProvider, messageType); + } + + public TcrMessageBuilder AddPart(Func> func) + { + _tcrPartBuilders.Add(new TcrPartBuilder(func)); + return this; + } + + public TcrMessageBuilder AddKeepAlivePart(bool value) + { + _tcrPartBuilders.Add(TcrPartBuilder.KeepAlive(value)); + return this; + } + + public TcrMessageBuilder AddRegionNamePart(string regionName) + { + _tcrPartBuilders.Add(TcrPartBuilder.RegionName(_serviceProvider, regionName)); + return this; + } + + public TcrMessageBuilder AddKeyPart(GeodeCache cache, object key) + { + return AddPart(async (ct) => + { + using var output = ActivatorUtilities.CreateInstance(_serviceProvider); + await cache.SerializationRegistry.WriteObjectAsync(output, key, ct: ct); + return new TcrPart(IsObject: 1, output.WrittenSpan.ToArray()); + }); + } + + public TcrMessageBuilder AddInt32Part(int value) + { + _tcrPartBuilders.Add(TcrPartBuilder.Int32(_serviceProvider, value)); + return this; + } + + public TcrMessageBuilder AddNullObjectPart() + { + _tcrPartBuilders.Add(TcrPartBuilder.NullObj()); + return this; + } + + /// + /// Add a -tagged 1-byte part + /// (IsObject=1). Used for the isDelta slot in Put; + /// mirrors cppcache writeObjectPart(CacheableBoolean::create(...)). + /// + public TcrMessageBuilder AddCacheableBooleanPart(bool value) + { + _tcrPartBuilders.Add(TcrPartBuilder.CacheableBoolean(value)); + return this; + } + + /// + /// Add a DSCode-tagged serialized part + /// (IsObject=1); structurally identical to + /// , named separately for caller semantics. + /// Mirrors cppcache writeObjectPart(value, isDelta) minus + /// delta support (Phase 4+). + /// + public TcrMessageBuilder AddValuePart(GeodeCache cache, object value) + { + return AddPart(async ct => + { + using var output = ActivatorUtilities.CreateInstance(_serviceProvider); + await cache.SerializationRegistry.WriteObjectAsync(output, value, ct: ct); + return new TcrPart(IsObject: 1, output.WrittenSpan.ToArray()); + }); + } + + /// + /// Add the 18-byte EventId part (IsObject=0) mirroring + /// cppcache writeEventIdPart + /// (cppcache/src/TcrMessage.cpp:834): longCode-tagged + /// + , + /// both BE. Pair sourced from + /// . + /// + public TcrMessageBuilder AddEventIdPart(long threadId, long sequenceId) + { + _tcrPartBuilders.Add(TcrPartBuilder.EventId(_serviceProvider, threadId, sequenceId)); + return this; + } + + public async ValueTask BuildAsync(CancellationToken ct = default) + { + var parts = new List(); + foreach (var builder in _tcrPartBuilders) + { + parts.Add(await builder.BuildAsync(ct)); + } + + // Direct construction (record positional ctor) rather than + // ActivatorUtilities — BuildAsync is called from CloseAsync on the + // sp-teardown path, and ActivatorUtilities would re-enter the + // disposing ServiceProvider to resolve `IServiceProvider`, throwing + // ObjectDisposedException. We already hold every ctor arg. + return new TcrMessage(_serviceProvider, _messageType, _transactionId, _earlyAck, parts); + + } +} + diff --git a/src/Geode.Client/Protocol/TcrMessageHelper.cs b/src/Geode.Client/Protocol/TcrMessageHelper.cs new file mode 100644 index 0000000..ca2764e --- /dev/null +++ b/src/Geode.Client/Protocol/TcrMessageHelper.cs @@ -0,0 +1,176 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Protocol; + +/// +/// Static helpers around the chunked-reply wire format. Mirrors +/// cppcache TcrMessageHelper +/// (cppcache/src/TcrMessage.cpp:3181-3251). +/// +/// +/// Phase 1.3.b ??only is sketched +/// (NIE body). cppcache's other helpers +/// (readExceptionPart / skipParts) land when their +/// callers do. +/// +internal sealed class TcrMessageHelper(ILogger logger) +{ + /// + /// Chunk-part dispatch result from . + /// Mirrors cppcache TcrMessageHelper::ChunkObjectType. + /// + public enum ChunkObjectType + { + /// Empty chunk — server has no result to ship. + NullObject, + + /// Standard object chunk; caller decodes per its + /// expected DSFid (e.g. VersionedObjectPartList). + Object, + + /// Server-side exception encoded as a Java-serialised + /// blob. Caller throws. + Exception, + + /// + /// Single-hop PR-metadata refresh prelude — 2 raw bytes + /// [metadataVersion][networkHopType]. cppcache returns + /// this via the second overload of readChunkPartHeader; + /// we collapse into one enum so the caller can switch on a + /// single value. Phase 4+ work. + /// + Bytes, + } + + /// + /// Read a chunk's leading part header and classify the chunk + /// shape. Mirrors cppcache + /// TcrMessageHelper::readChunkPartHeader + /// (cppcache/src/TcrMessage.cpp:3191-3251). + /// + /// Reader positioned at the start of the chunk. + /// Expected leading + /// for the OBJECT path (e.g. + /// ). + /// Expected fixed id following the + /// DSCode (e.g. (int)DSFid.VersionedObjectPartList). + /// Caller name; goes into log / + /// exception messages. + /// Out: the i32 partLen prefix the reader + /// just consumed. + /// Flags byte from the chunk header + /// (only consumed via readExceptionPart on the EXCEPTION + /// branch). + public ChunkObjectType ReadChunkPartHeader( + DataInput reader, + byte expectedDsCode, + int expectedPartType, + string methodName, + out int partLen, + byte isLastChunk) + { + ArgumentNullException.ThrowIfNull(reader); + + partLen = reader.ReadInt32(); + var isObj = reader.ReadBool(); + + + if (partLen == 0) + { + return ChunkObjectType.NullObject; + } + + + if (!isObj) + { + logger.LogDebug( + "TcrMessageHelper::readChunkPartHeader: {MethodName}: part is not object", + methodName); + return ChunkObjectType.Exception; + } + + + var partType = reader.ReadByte(); + var compId = (int)partType; + + if (partType == DSCode.JavaSerializable) + { + logger.LogDebug( + "TcrMessageHelper::readChunkPartHeader: {MethodName}: " + + "java-serialised exception chunk", + methodName); + return ChunkObjectType.Exception; + } + + + if (partType == DSCode.NullObj) + { + return ChunkObjectType.NullObject; + } + + + if (expectedDsCode > DSCode.FixedIDDefault) + { + if (partType != expectedDsCode) + { + throw new GeodeException( + $"TcrMessageHelper.ReadChunkPartHeader: {methodName}: " + + $"got unhandled object class = {(sbyte)partType}"); + } + if (expectedDsCode == DSCode.FixedIDShort) + { + compId = reader.ReadInt16(); + } + else if (expectedDsCode == DSCode.FixedIDByte) + { + compId = (sbyte)reader.ReadByte(); + } + } + + + if (compId != expectedPartType) + { + throw new GeodeException( + $"TcrMessageHelper.ReadChunkPartHeader: {methodName}: " + + $"got unhandled object type = {compId}, " + + $"expected = {expectedPartType}, raw = {(int)partType}"); + } + + + _ = isLastChunk; + return ChunkObjectType.Object; + } + + /// + /// Best-effort ASCII preview of an Exception reply's Part 0. The + /// server typically returns the Java exception class name + message + /// there as a CacheableASCIIString; until StringDataConverter + /// lands we render printable bytes directly so the caller sees a + /// readable hint in the message. + /// + /// + /// Mirror of cppcache TcrMessageHelper::readExceptionPart + /// (cppcache/src/TcrMessage.cpp:3253) — but stripped down: + /// cppcache actually deserialises the Java exception object, we + /// just dump printable ASCII for diagnostics. Upgrades when + /// StringDataConverter + Java exception deserialise land. + /// + public static string DecodeExceptionPreview(TcrMessage reply) + { + ArgumentNullException.ThrowIfNull(reply); + + if (reply.Parts.Count == 0) + { + return ""; + } + + var bytes = reply.Parts[0].Payload.Span; + var sb = new StringBuilder(bytes.Length); + foreach (var b in bytes) + { + sb.Append(b is >= 0x20 and < 0x7F ? (char)b : '.'); + } + return sb.ToString(); + } +} diff --git a/src/Geode.Client/Protocol/TcrPart.cs b/src/Geode.Client/Protocol/TcrPart.cs new file mode 100644 index 0000000..262f512 --- /dev/null +++ b/src/Geode.Client/Protocol/TcrPart.cs @@ -0,0 +1,40 @@ +namespace Geode.Client.Protocol; + +internal sealed record TcrPart(byte IsObject, ReadOnlyMemory Payload) +{ + /// + /// Serialise this Part onto . + /// + public void Encode(DataOutput writer) + { + writer.WriteInt32(Payload.Length); + writer.WriteByte(IsObject); + writer.WriteBytesOnly(Payload.Span); + } + + public static TcrPart Decode(DataInput reader) + { + var length = reader.ReadInt32(); + if (length < 0) + { + throw new FormatException( + $"TcrPart length must be non-negative, got {length}."); + } + var isObject = reader.ReadByte(); + var payload = reader.ReadBytesOnly(length); + return new TcrPart(isObject, payload); + } + + public bool Equals(TcrPart? other) => + other is not null + && IsObject == other.IsObject + && Payload.Span.SequenceEqual(other.Payload.Span); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(IsObject); + hash.AddBytes(Payload.Span); + return hash.ToHashCode(); + } +} diff --git a/src/Geode.Client/Protocol/TcrPartBuilder.cs b/src/Geode.Client/Protocol/TcrPartBuilder.cs new file mode 100644 index 0000000..5a22c8b --- /dev/null +++ b/src/Geode.Client/Protocol/TcrPartBuilder.cs @@ -0,0 +1,277 @@ +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class TcrPartBuilder(Func> func) +{ + public async ValueTask BuildAsync(CancellationToken ct = default) + { + return await func.Invoke(ct); + } + + public static TcrPartBuilder RawBytes(ReadOnlyMemory bytes) + => new((_) => ValueTask.FromResult(new TcrPart(0, bytes))); + + /// + /// Build a Part whose payload is a raw byte sequence (IsObject=0), + /// composed by . Use for EventId and other + /// non-DSCode-tagged compound payloads. + /// + /// Body writer. + /// Optional initial buffer size hint. + public static TcrPartBuilder Raw(IServiceProvider serviceProvider, Action write, int sizeHint = 0) => + Build(serviceProvider, 0, sizeHint, write); + + public static TcrPartBuilder KeepAlive(bool value) + => RawBytes(new byte[] { (byte)(value ? 1 : 0) }); + + public static TcrPartBuilder RegionName(IServiceProvider serviceProvider, string regionName) + => ModifiedUtf8(serviceProvider, regionName); + + /// + /// Single i32 BE payload, IsObject=0. Mirrors cppcache + /// writeIntPart. + /// + public static TcrPartBuilder Int32(IServiceProvider serviceProvider, int value) => + Raw(serviceProvider, w => w.WriteInt32(value), sizeHint: sizeof(int)); + public static TcrPartBuilder Build(IServiceProvider serviceProvider, + byte isObject, int _, Action write) + { + return new TcrPartBuilder((_) => + { + // sizeHint hint is no longer plumbed (DataOutput starts at 8 KB + // and grows). Re-add if a workload shows up needing tight control. + //_ = sizeHint; + + using var output = ActivatorUtilities.CreateInstance(serviceProvider); + write(output); + // Copy out — output's buffer returns to ArrayPool on Dispose. + return ValueTask.FromResult(new TcrPart(isObject, output.WrittenSpan.ToArray())); + }); + } + + /// + /// Build a Part whose payload is encoded as + /// Java Modified UTF-8 (IsObject=0, no length prefix + /// inside the payload — the Part header alone carries the + /// length). + /// + /// + /// + /// Mirrors what the Geode Java server expects for region-name / + /// OQL parts: it decodes via + /// CacheServerHelper.fromUTF(byte[]) + /// (geode-core/.../Part.java:174 + + /// CacheServerHelper.java:116), the standard Java + /// DataInput.readUTF decoder. Bytes hit the wire raw ??the + /// u16 length prefix that readUTF would normally consume is + /// absent because the surrounding Part header already supplies the + /// length. + /// + /// + /// cppcache's writeRegionPart does not encode at all — + /// it writes the bytes of the caller's std::string verbatim. + /// That happens to match server-side modified-UTF-8 for the typical + /// BMP / non-NUL characters real-world callers pass, but corrupts + /// on NUL (one byte vs 0xC0 0x80) and supplementary-plane + /// code points (UTF-8 4-byte form vs modified UTF-8's 6-byte + /// surrogate pair). This helper does the encoding explicitly so we + /// stay correct in the corner cases cppcache silently mishandles. + /// + /// + /// Encoding logic duplicates the body pass of + /// (which + /// also emits a u16 prefix we do not want for raw Parts). If a + /// third caller materialises, extract a shared body writer. + /// + /// + /// + /// is . + /// + public static TcrPartBuilder ModifiedUtf8(IServiceProvider serviceProvider, string value) + { + ArgumentNullException.ThrowIfNull(value); + + // Pass 1 ??pre-compute the byte length so the Part buffer is + // sized exactly (no dynamic growth, no oversize allocation). + var byteLen = 0; + foreach (var c in value) + { + if (c >= 0x0001 && c <= 0x007F) byteLen += 1; + else if (c == 0 || (c >= 0x0080 && c <= 0x07FF)) byteLen += 2; + else byteLen += 3; + } + + // Pass 2 ??emit the bytes through the standard Raw(IsObject=0) + // path. Per-char branch matches Java DataOutput.writeUTF body + // exactly (BMP only; supplementary chars arrive here as two + // UTF-16 surrogate halves, each emitted as 3 bytes = 6 bytes + // total ??same as Java). + return Raw(serviceProvider, w => + { + foreach (var c in value) + { + if (c >= 0x0001 && c <= 0x007F) + { + w.WriteByte((byte)c); + } + else if (c == 0 || (c >= 0x0080 && c <= 0x07FF)) + { + w.WriteByte((byte)(0xC0 | (c >> 6))); + w.WriteByte((byte)(0x80 | (c & 0x3F))); + } + else + { + w.WriteByte((byte)(0xE0 | (c >> 12))); + w.WriteByte((byte)(0x80 | ((c >> 6) & 0x3F))); + w.WriteByte((byte)(0x80 | (c & 0x3F))); + } + } + }, sizeHint: byteLen); + } + + /// + /// One-byte payload of , IsObject=1. + /// Used for operation slots and missing value markers. + /// + public static TcrPartBuilder NullObj() + { + return new TcrPartBuilder(_ => ValueTask.FromResult(new TcrPart(IsObject: 1, Payload: new byte[] { DSCode.NullObj }))); + } + + /// + /// + 1 byte payload, IsObject=1. + /// Mirrors cppcache CacheableBoolean::create(value) wrapped in + /// writeObjectPart — used for the isDelta slot in Put + /// and similar boolean flags inside Geode messages. + /// + public static TcrPartBuilder CacheableBoolean(bool value) + => new(_ => ValueTask.FromResult( + new TcrPart(IsObject: 1, Payload: new byte[] { DSCode.CacheableBoolean, (byte)(value ? 1 : 0) }))); + + /// + /// 18-byte EventId part (IsObject=0) mirroring cppcache + /// EventId::writeIdsData (cppcache/src/EventId.hpp:95-107): + /// longCode(0x03) + i64 threadId + longCode(0x03) + i64 sequenceId, + /// all big-endian. The length prefix and IsObject byte ride on + /// the surrounding Part header. + /// + public static TcrPartBuilder EventId(IServiceProvider serviceProvider, long threadId, long sequenceId) => + + Raw(serviceProvider, w => + { + // cppcache writes longCode 0x03 before each i64 — signals + // "next value is 8-byte long" to Java DataInput parity. + const byte EventIdLongCode = 3; + w.WriteByte(EventIdLongCode); + w.WriteInt64(threadId); + w.WriteByte(EventIdLongCode); + w.WriteInt64(sequenceId); + }, sizeHint: 18); +} + +/* +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client.Protocol; + +/// +/// Helpers for constructing values without +/// repeating the buffer / writer plumbing at every call site. +/// +/// +/// +/// A Part is i32 length + u8 IsObject + payload bytes +/// (see ). Building one usually means: allocate a +/// buffer, wrap a , write the +/// payload, then snapshot the bytes. The handful of methods here cover +/// the common shapes that show up across cppcache's writeXxxPart +/// helpers in cppcache/src/TcrMessage.cpp: +/// +/// +/// ??a verbatim byte buffer with +/// IsObject=0 (region name, byte[] CacheableBytes shortcut). +/// ??single i32 BE payload, IsObject=0 +/// (flags). Mirrors writeIntPart. +/// ??single DSCode-41 byte (operation +/// placeholder, missing value sentinel). +/// ??DSCode-53 + 1 byte +/// (isDelta, optional flags). +/// ??special IsObject=2 +/// empty-payload sentinel for empty byte[] values. +/// ??payload begins with a DSCode and the +/// caller writes the body via the writer. +/// ??like but +/// IsObject=0 (EventId, raw blobs). +/// +/// +internal sealed class TcrPartBuilder(IServiceProvider serviceProvider) +{ + + /// + /// Build a Part for a region path / OQL string — thin + /// semantic wrapper over . cppcache's + /// writeRegionPart writes raw std::string bytes + /// without conversion; for typical ASCII region paths + /// (/orders, /users) Modified UTF-8 produces the + /// same bytes, so wire compatibility is preserved. For non-ASCII + /// region names we get the encoding cppcache de-facto relies on + /// (its callers ship UTF-8 std::strings) but does not + /// guarantee. + /// + public TcrPart RegionName(string regionName) => ModifiedUtf8(regionName); + + + + + + + + + /// + /// CacheableBoolean ( + 1 byte), + /// IsObject=1. + /// + public TcrPart CacheableBoolean(bool value) => + new( + IsObject: 1, + Payload: new byte[] { DSCode.CacheableBoolean, value ? (byte)1 : (byte)0 }); + + /// + /// Empty CacheableBytes sentinel ??IsObject=2, zero-length + /// payload. Mirrors the empty branch of cppcache + /// writeObjectPart's CacheableBytes path. + /// + public TcrPart EmptyCacheableBytes() => + new(IsObject: 2, Payload: ReadOnlyMemory.Empty); + + /// + /// Build a Part whose payload starts with a DSCode (IsObject=1). + /// Caller writes the entire body ??including the leading DSCode byte + /// ??via . + /// + /// Body writer; typically calls one of + /// , + /// WriteByte(DSCode.X) + WriteInt32(...), etc. + /// Optional initial buffer size hint. + public TcrPart Object(Action write, int sizeHint = 0) => + Build(isObject: 1, sizeHint, write); + + + public ValueTask ObjectAsync(Func write, int sizeHint = 0) => + BuildAsync(isObject: 1, sizeHint, write); + + + + + + private async ValueTask BuildAsync(byte isObject, int sizeHint, Func write) + { + _ = sizeHint; + + using var output = ActivatorUtilities.CreateInstance(serviceProvider); + await write(output); + return new TcrPart(isObject, output.WrittenSpan.ToArray()); + } +} + +*/ diff --git a/src/Geode.Client/Protocol/VersionTag.cs b/src/Geode.Client/Protocol/VersionTag.cs new file mode 100644 index 0000000..1214002 --- /dev/null +++ b/src/Geode.Client/Protocol/VersionTag.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Protocol; + +/// +/// Per-entry server-side version stamp shipped back with bulk-op +/// replies (PutAll / RemoveAll / GetAll) and used by client-side +/// caching to resolve concurrent modifications. Mirrors cppcache +/// VersionTag +/// (cppcache/src/VersionTag.hpp). +/// +/// +/// +/// Phase 1.3.b status: members only, no decoder. +/// + throw +/// ; bodies land alongside +/// step 6 +/// (Phase 1.3.c GetAll or Phase 4+ client-side caching). +/// +/// +/// cppcache derives from DataSerializableFixedId with +/// DSFid.VersionTag; we don't have the interface yet +/// (Phase 4 ToData/FromData contract), so the wire methods sit +/// directly on the class. +/// +/// +/// DiskVersionTag dropped. cppcache has a +/// DiskVersionTag subclass for persistent regions +/// (persistent flag in +/// ). Phase +/// 1.3 RemoveAll doesn't target persistent regions; revisit when +/// the persistent-region scenario lands. +/// +/// +// Phase 1.3.b: fields below are placeholder shells until FromData +// lands. They mirror cppcache m_* members 1:1. +#pragma warning disable CS0169 // field never used +#pragma warning disable CS0649 // field never assigned +internal class VersionTag( + IServiceProvider serviceProvider, + ILogger logger, + MemberListForVersionStamp? memberListForVersionStamp = null) +{ + // ── Wire flag bits (used by FromData) ────────────────────── + // cppcache static const uint8_t HAS_MEMBER_ID = 0x01; etc. + protected const byte HAS_MEMBER_ID = 0x01; + protected const byte HAS_PREVIOUS_MEMBER_ID = 0x02; + protected const byte VERSION_TWO_BYTES = 0x04; + protected const byte DUPLICATE_MEMBER_IDS = 0x08; + protected const byte HAS_RVV_HIGH_BYTE = 0x10; + + // ── Bits-field interpretation (m_bits) ───────────────────── + protected const byte BITS_POSDUP = 0x01; + protected const byte BITS_RECORDED = 0x02; + protected const byte BITS_HAS_PREVIOUS_ID = 0x03; + + // ── Wire fields (mirror cppcache m_*) ────────────────────── + private ushort _bits; + private int _entryVersion; + private short _regionVersionHighBytes; + private int _regionVersionLowBytes; + private ushort _internalMemId; + private ushort _previousMemId; + private long _timeStamp; + + /// + /// cppcache m_memberListForVersionStamp + /// (MemberListForVersionStamp&). Typed as + /// ? until that class lands — Phase 4+ when + /// the cluster's member-id resolution table is wired through. + /// + protected readonly MemberListForVersionStamp? MemberListForVersionStamp = memberListForVersionStamp; + + // ── Getters / setters needed by VersionedCacheableObjectPartList.FromData ── + // cppcache exposes most as inline accessors; we mirror. + + public int EntryVersion => _entryVersion; + public short RegionVersionHighBytes => _regionVersionHighBytes; + public int RegionVersionLowBytes => _regionVersionLowBytes; + public ushort PreviousMemId => _previousMemId; + + /// + /// Internal member-id slot, settable from + /// 's + /// FLAG_TAG_WITH_NUMBER_ID branch (looks up the id from + /// the ids vector built during this chunk). + /// + public ushort InternalMemId + { + get => _internalMemId; + set => _internalMemId = value; + } + + /// + /// Decode the wire bytes. Mirrors cppcache + /// VersionTag::fromData + /// (cppcache/src/VersionTag.cpp). + /// + /// + /// Body lands with the chunked-reply consumer + /// ( + /// step 6) in Phase 1.3.c / Phase 4+. Reads m_bits, then + /// dispatches on the 5 flag bits (HAS_MEMBER_ID / + /// HAS_PREVIOUS_MEMBER_ID / VERSION_TWO_BYTES / + /// DUPLICATE_MEMBER_IDS / HAS_RVV_HIGH_BYTE) to read the + /// variable-width fields. + /// + internal virtual void FromData(DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + + // Mirrors cppcache VersionTag::fromData + // (cppcache/src/VersionTag.cpp:48-65). + // + // ─── Step 1: read flags ──────────────────────────────── + var flags = reader.ReadUInt16(); + + // ─── Step 2: read _bits ──────────────────────────────── + _bits = reader.ReadUInt16(); + + // ─── Step 3: skip distributedSystemId byte ───────────── + // cppcache reads + discards. Phase 4+ multi-cluster routing + // may surface this; Phase 1.3 single-cluster doesn't need it. + reader.AdvanceCursor(1); + + // ─── Step 4: read _entryVersion (16 or 32 bit) ───────── + // cppcache masks the result with 0xffff / 0xffffffff to + // erase any sign-extension; we read unsigned for the 16-bit + // path and assign directly into int (implicit widening), + // which has the same effect without the mask. + if ((flags & VERSION_TWO_BYTES) != 0) + { + _entryVersion = reader.ReadUInt16(); + } + else + { + _entryVersion = reader.ReadInt32(); + } + + // ─── Step 5: read _regionVersionHighBytes (optional) ─── + if ((flags & HAS_RVV_HIGH_BYTE) != 0) + { + _regionVersionHighBytes = reader.ReadInt16(); + } + + // ─── Step 6: read _regionVersionLowBytes ─────────────── + _regionVersionLowBytes = reader.ReadInt32(); + + // ─── Step 7: read _timeStamp (VL unsigned) ───────────── + _timeStamp = (long)reader.ReadUnsignedVL(); + + // ─── Step 8: dispatch ReadMembers (virtual hook) ─────── + // Base VersionTag reads ClientProxyMembershipID; + // DiskVersionTag override reads DiskStoreId. + logger.LogTrace( + "VersionTag::fromData flags=0x{Flags:X4} bits=0x{Bits:X4} " + + "entryVersion={EntryVersion} regionVersionLow={RegionVersionLow} " + + "timeStamp={TimeStamp}", + flags, _bits, _entryVersion, _regionVersionLowBytes, _timeStamp); + ReadMembers(flags, reader); + } + + /// + /// If is zero (server didn't ship a + /// member id because it's the same as the endpoint's own), + /// substitute . Mirrors cppcache + /// replaceNullMemberId. + /// + internal void ReplaceNullMemberId(ushort memId) + { + // Mirrors cppcache VersionTag::replaceNullMemberId + // (cppcache/src/VersionTag.cpp:72-79). + if (_previousMemId == 0) + { + _previousMemId = memId; + } + if (_internalMemId == 0) + { + _internalMemId = memId; + } + } + + /// + /// Member-id wire decode hook called from inside + /// . Mirrors cppcache + /// VersionTag::readMembers + /// (cppcache/src/VersionTag.cpp); subclass + /// overrides to read + /// DiskStoreId instead of InternalDistributedMember. + /// + /// + /// Virtual so the persistent-region path + /// () can swap the member-id codec + /// without re-implementing . Phase 4+ when + /// the actual member-id wire types land. + /// + protected virtual void ReadMembers(ushort flags, DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + + // Mirrors cppcache VersionTag::readMembers + // (cppcache/src/VersionTag.cpp:80-96). + // + // ─── Step 1: HAS_MEMBER_ID → read internal member id ─── + if ((flags & HAS_MEMBER_ID) != 0) + { + if (MemberListForVersionStamp is null) + { + throw new GeodeException( + "VersionTag.ReadMembers: HAS_MEMBER_ID flag set but " + + "MemberListForVersionStamp was not provided to ctor."); + } + var internalMemId = ActivatorUtilities.CreateInstance(serviceProvider); + internalMemId.ReadEssentialData(reader); + _internalMemId = MemberListForVersionStamp.Add(internalMemId); + } + + // ─── Step 2: HAS_PREVIOUS_MEMBER_ID → read previous id ─ + // DUPLICATE_MEMBER_IDS short-circuit: previous member is the + // same as internal — reuse the id we just registered instead + // of reading + adding the same payload twice. + if ((flags & HAS_PREVIOUS_MEMBER_ID) != 0) + { + if ((flags & DUPLICATE_MEMBER_IDS) != 0) + { + _previousMemId = _internalMemId; + } + else + { + if (MemberListForVersionStamp is null) + { + throw new GeodeException( + "VersionTag.ReadMembers: HAS_PREVIOUS_MEMBER_ID flag set " + + "but MemberListForVersionStamp was not provided to ctor."); + } + var previousMemId = ActivatorUtilities.CreateInstance(serviceProvider); + previousMemId.ReadEssentialData(reader); + _previousMemId = MemberListForVersionStamp.Add(previousMemId); + } + } + } +} diff --git a/src/Geode.Client/Protocol/VersionedCacheableObjectPartList.cs b/src/Geode.Client/Protocol/VersionedCacheableObjectPartList.cs new file mode 100644 index 0000000..1d3c394 --- /dev/null +++ b/src/Geode.Client/Protocol/VersionedCacheableObjectPartList.cs @@ -0,0 +1,595 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Protocol; + +/// +/// Per-key result list shipped back in the chunked replies of bulk +/// ops (, +/// , +/// ). Mirrors cppcache +/// VersionedCacheableObjectPartList +/// (cppcache/src/VersionedCacheableObjectPartList.hpp). +/// +/// +/// +/// Phase 1.3.b status: members only, no decoder. Field shape +/// mirrors cppcache (1:1 with the m_* instance members) so +/// the wire decoder (fromData) and merge (addAll) can +/// land later without re-shaping. Phase 1.3 bulk ops discard per-key +/// results; Phase 4+ (client-side caching / concurrency checks) wires +/// them through. +/// +/// +/// Inherits from (cppcache +/// CacheableObjectPartList base), which holds keys / values +/// / exceptions / region back-ref / tracker maps. This class adds +/// the version-tag tier on top. +/// +/// +// _endpointMemId stays placeholder until Phase 4 single-hop wires +// the server-side member id through ctor; CS0649 suppresses the +// "never assigned" warning until then. +#pragma warning disable CS0649 +internal sealed class VersionedCacheableObjectPartList( + IServiceProvider serviceProvider, + SerializationRegistry serializationRegistry, + ILogger logger, + RegionInternal region) : CacheableObjectPartList(region) +{ + // ── Version-tag entryType wire flags (FromData step 6) ───── + // cppcache static const uint8_t FLAG_NULL_TAG = 0; etc. + // (cppcache/src/VersionedCacheableObjectPartList.cpp:33-36). + private const byte FLAG_NULL_TAG = 0; + private const byte FLAG_FULL_TAG = 1; + private const byte FLAG_TAG_WITH_NEW_ID = 2; + private const byte FLAG_TAG_WITH_NUMBER_ID = 3; + + /// cppcache m_regionIsVersioned. + private bool _regionIsVersioned; + + /// cppcache m_serializeValues. + private bool _serializeValues; + + /// cppcache m_hasTags — true once any + /// VersionTag has been read into . + private bool _hasTags; + + /// cppcache m_hasKeys — true once any key has + /// been read into (GetAll path). + private bool _hasKeys; + + /// + /// Per-key version tags read off the wire. Mirrors cppcache + /// m_versionTags + /// (std::vector<std::shared_ptr<VersionTag>>); + /// element nullable to represent the FLAG_NULL_TAG slot. + /// + private readonly List _versionTags = []; + + /// + /// Per-key miss-flag byte: 0 = present, 3 = key + /// absent on server, 2 = exception, etc. Mirrors cppcache + /// m_byteArray (std::vector<uint8_t>). + /// + private readonly List _byteArray = []; + + /// + /// Server's endpoint-memory id at the time the chunk arrived. + /// Used by single-hop (Phase 4) to attribute version tags to the + /// right server. Mirrors cppcache m_endpointMemId. + /// + private ushort _endpointMemId; + + /// + /// Keys read off the wire (GetAll path only). Element type + /// — keys are already object-typed in + /// our codec (cppcache wraps them in CacheableKey which we + /// don't mirror as a separate class). Mirrors cppcache + /// m_tempKeys. + /// + private readonly List _tempKeys = []; + + /// + /// The accumulated per-key version-tag list. Mirrors cppcache + /// getVersionedTagptr() + /// (cppcache/src/VersionedCacheableObjectPartList.hpp:154-156) + /// — returns the inner list directly so callers + /// (chunked-reply handlers, Reset) can mutate it without + /// going through a dedicated method. + /// + internal IList VersionTags => _versionTags; + + /// + /// Number of (miss-flag, value) entries decoded in this chunk's + /// objects section. Used by + /// to advance the shared KeysOffset across chunks — cppcache + /// passes m_keysOffset as uint32_t* so the cursor is + /// shared by reference between chunks; .NET prefers an explicit + /// post-read read-back. + /// + internal int ConsumedObjectCount => _byteArray.Count; + + /// + /// Wire the shared GetAll accumulators into this per-chunk instance + /// before runs. Mirrors cppcache's + /// VersionedCacheableObjectPartList 10-arg ctor + /// (cppcache/src/ThinClientRegion.cpp:3640-3643): the + /// chunked-response handler creates a fresh + /// per chunk but + /// passes pointers / shared_ptrs to the same outer accumulators so + /// each chunk merges into the same dict. + /// + /// + /// keys is the original keys we sent (so step 5 of + /// can index by position with + /// Keys[index + KeysOffset]); values / + /// exceptions / resultKeys are the accumulating + /// dictionaries / list. addToLocalCache stays + /// false for Phase 1.3 (no client-side caching) — gating + /// step 7's NIE on this flag keeps the GetAll round-trip alive. + /// + internal void Initialize( + IReadOnlyList keys, + int keysOffset, + Dictionary values, + Dictionary? exceptions, + List? resultKeys, + bool addToLocalCache) + { + ArgumentNullException.ThrowIfNull(keys); + ArgumentNullException.ThrowIfNull(values); + Keys = keys; + KeysOffset = keysOffset; + Values = values; + Exceptions = exceptions; + ResultKeys = resultKeys; + AddToLocalCache = addToLocalCache; + } + + /// + /// Number of accumulated entries. Mirrors cppcache + /// VersionedCacheableObjectPartList::size() + /// (cppcache/src/VersionedCacheableObjectPartList.hpp:220-231): + /// returns size when keys are tracked, + /// size when only tags are tracked, + /// or -1 when neither flag is set (called too early). + /// + /// + /// Phase 1.3 never sets / + /// (decoder body not written yet), so + /// this always returns -1 — callers' Size > 0 + /// guards short-circuit correctly. + /// + internal int Size + { + get + { + if (_hasKeys) return _tempKeys.Count; + if (_hasTags) return _versionTags.Count; + return -1; + } + } + + /// + /// Decode the wire bytes of one + /// VersionedCacheableObjectPartList chunk into this + /// instance's fields. Mirrors cppcache + /// fromData(DataInput&) + /// (cppcache/src/VersionedCacheableObjectPartList.hpp:247). + /// + /// + /// Phase 4+ — full decoder lands with the + /// VersionTag wire-format work + /// (FLAG_NULL_TAG / FLAG_FULL_TAG / FLAG_TAG_WITH_NEW_ID / + /// FLAG_TAG_WITH_NUMBER_ID branches). + /// + internal void FromData(DataInput reader) + { + ArgumentNullException.ThrowIfNull(reader); + + // cppcache wraps the whole body in + // std::lock_guard guard(m_responseLock); + // .NET equivalent is a single `lock` over the response lock + // object. Phase 1.3 chunked drain is single-task / sequential + // so the lock is uncontended today; Phase 4+ background chunk + // processors (multiple chunks merged concurrently) make it + // necessary. + lock (_responseLock) + { + // Mirrors cppcache VersionedCacheableObjectPartList::fromData + // (cppcache/src/VersionedCacheableObjectPartList.cpp:92-305). + // + // ─── Step 1: read flags byte → 6 bool fields ────────── + // cppcache VersionedCacheableObjectPartList.cpp:95-101. + // hasObjects + persistent are loop-locals (decide subsequent + // reads + which VersionTag subtype to construct); _hasKeys / + // _hasTags / _regionIsVersioned / _serializeValues land on + // the instance for later AddAll / Size / Reset to read. + var flags = reader.ReadByte(); + _hasKeys = (flags & 0x01) == 0x01; + var hasObjects = (flags & 0x02) == 0x02; + _hasTags = (flags & 0x04) == 0x04; + _regionIsVersioned = (flags & 0x08) == 0x08; + _serializeValues = (flags & 0x10) == 0x10; + var persistent = (flags & 0x20) == 0x20; + _ = hasObjects; // consumed in step 5+7 + _ = persistent; // consumed in step 6 (VersionTag vs DiskVersionTag) + + // ─── Step 2: init Values if null ────────────────────── + // cppcache lazy-inits HashMapOfCacheable when caller didn't + // supply one via the 6-ctor path (VersionedCacheableObjectPartList.cpp:108-111). + // Phase 1.3 only has the default ctor so Values is always null + // here; init keeps the shape parity for future GetAll-style + // ctors that may supply a pre-existing dict. + Values ??= []; + + // ─── Step 3: empty-message diagnostic ────────────────── + // cppcache logs and falls through; the downstream hasKeys / + // hasObjects / hasTags branches skip naturally when their + // flags are off, so no explicit short-circuit return needed. + if (!_hasKeys && !hasObjects && !_hasTags) + { + logger.LogDebug( + "VersionedCacheableObjectPartList::fromData: Looks like message has no data. Returning,"); + } + + + // ─── Step 4: read keys section ───────────────────────── + // cppcache VersionedCacheableObjectPartList.cpp:119-167. + // localKeys collects this chunk's keys for step 5 (objects) + // and step 6 (version tags) to index by position. Phase 1.3 + // RemoveAll reply never sets _hasKeys (server doesn't echo + // keys), so localKeys stays empty here and the hasKeys NIE + // branch is unreachable today; GetAll (Phase 1.3.c) will + // exercise it. + var localKeys = new List(); + if (_hasKeys) + { + // cppcache VersionedCacheableObjectPartList.cpp:121-131. + var keyCount = (int)reader.ReadUnsignedVL(); + for (var i = 0; i < keyCount; i++) + { + var key = serializationRegistry.ReadObject(reader) + ?? throw new GeodeException( + "VersionedCacheableObjectPartList.FromData: null key " + + "in keys section."); + ResultKeys?.Add(key); + _tempKeys.Add(key); + localKeys.Add(key); + } + } + else if (Keys is not null) + { + logger.LogDebug( + "VersionedCacheableObjectPartList::fromData: m_keys NOT nullptr"); + } + else if (hasObjects) + { + if (Keys is null && ResultKeys is null) + { + logger.LogError( + "VersionedCacheableObjectPartList::fromData: Exception: hasObjects " + + "is true and m_keys and m_resultKeys are also nullptr"); + throw new GeodeException( + "VersionedCacheableObjectPartList: " + + "hasObjects is true and m_keys is also nullptr"); + } + logger.LogDebug( + "VersionedCacheableObjectPartList::fromData m_keys or m_resultKeys not null"); + } + else + { + logger.LogDebug( + "VersionedCacheableObjectPartList::fromData m_hasKeys, m_keys, hasObjects all are nullptr"); + } + + + // ─── Step 5: read objects section ────────────────────── + // cppcache VersionedCacheableObjectPartList.cpp:169-183. + // Each entry is preceded by a 1-byte miss flag + // (_byteArray[i]; 0 = present, 2 = exception, 3 = miss on + // server). Phase 1.3 RemoveAll reply has hasObjects=false + // (server doesn't echo values, only version tags), so the + // branch is dead today; GetAll (Phase 1.3.c) will exercise it. + if (hasObjects) + { + var objCount = (int)reader.ReadUnsignedVL(); + _byteArray.Clear(); + for (var i = 0; i < objCount; i++) + { + _byteArray.Add(0); + } + for (var index = 0; index < objCount; index++) + { + // cppcache key selection: if caller supplied Keys + // (GetAll path) and we didn't read fresh keys in + // step 4, index into Keys[KeysOffset + i]; otherwise + // use the localKeys we just built in step 4. + var key = (Keys is not null && !_hasKeys) + ? Keys[index + KeysOffset] + : localKeys[index]; + ReadObjectPart(index, reader, key); + } + } + + + // ─── Step 6: read version tags section ───────────────── + // cppcache VersionedCacheableObjectPartList.cpp:185-249. + // Shared `len` between hasObjects (step 5) and hasTags + // sections: cppcache declares it at function-top with 0. + // Phase 1.3 step 5 NIEs before assigning, so len stays 0 if + // hasObjects was true (won't reach here anyway). When hasTags + // is true we read a fresh len off the wire. + var len = 0; + + // MemberListForVersionStamp resolved via DI inside + // NewVersionTag (Scoped, per-cache — mirrors cppcache + // CacheImpl::m_memberListForVersionStamp). cppcache passes + // it positionally to VersionTag::fromData via region-back-ref; + // .NET resolves it through ActivatorUtilities.CreateInstance + // so we don't need a local variable here any more. + + if (_hasTags) + { + // ReadUnsignedVL still NIE today; surfaces immediately + // when the server actually ships a versioned RemoveAll + // reply. Phase 1.3.c (PutAll/GetAll) or Phase 4+ supplies + // the VL decoder body. + len = (int)reader.ReadUnsignedVL(); + + // cppcache m_versionTags.resize(len): List has no + // Resize, replicate via Clear + Add-null loop so the + // index-assignments inside the for-loop are safe. + _versionTags.Clear(); + for (var i = 0; i < len; i++) + { + _versionTags.Add(null); + } + + var ids = new List(); + for (var index = 0; index < len; index++) + { + var entryType = reader.ReadByte(); + VersionTag? versionTag = null; + switch (entryType) + { + case FLAG_NULL_TAG: + // Null sentinel — leave versionTag = null. + break; + + case FLAG_FULL_TAG: + versionTag = NewVersionTag(persistent); + versionTag.FromData(reader); + versionTag.ReplaceNullMemberId(_endpointMemId); + break; + + case FLAG_TAG_WITH_NEW_ID: + versionTag = NewVersionTag(persistent); + versionTag.FromData(reader); + ids.Add(versionTag.InternalMemId); + break; + + case FLAG_TAG_WITH_NUMBER_ID: + versionTag = NewVersionTag(persistent); + versionTag.FromData(reader); + var idNumber = (int)reader.ReadUnsignedVL(); + versionTag.InternalMemId = ids[idNumber]; + break; + + default: + // cppcache default: break (silently drop). + break; + } + _versionTags[index] = versionTag; + } + } + else + { + // hasTags=false: null-fill `len` entries (size carried + // from step 5's hasObjects path). Phase 1.3 RemoveAll has + // hasObjects=false so len=0 — no-op. cppcache assigns by + // index assuming pre-sized vector; we Clear+Add to keep + // it safe under either path. + _versionTags.Clear(); + for (var index = 0; index < len; index++) + { + _versionTags.Add(null); + } + } + + + // ─── Step 7: putLocal merge ──────────────────────────── + // cppcache VersionedCacheableObjectPartList.cpp:251-301. + // For each per-key entry: write value into the client-side + // local cache (when AddToLocalCache=true) and reconcile any + // concurrent-modification conflict by demoting to the higher- + // version old value. + // + // Phase 1.3 reality: hasObjects is the gate, and step 5's NIE + // prevents reaching here at all when hasObjects=true. When + // hasObjects=false (our actual Phase 1.3 RemoveAll path) this + // branch is naturally skipped. Body lands with the client- + // side cache (Phase 4+). + // Phase 1.3 gate: cppcache walks every entry through Region.PutLocal + // (caching-enabled side) or Region.GetEntry (concurrent-version + // reconciliation side) regardless of AddToLocalCache, but every + // observable mutation funnels through PutLocal which we don't + // have yet (no client-side cache map). For proxy-mode regions + // (AddToLocalCache=false, the Phase 1.3 norm) the step is a + // pure no-op semantically — Values is already filled by step 5 + // and step 7's only job is the cache-side bookkeeping. + // + // We gate the NIE on AddToLocalCache so Phase 1.3 GetAll + // (which always lands here with AddToLocalCache=false because + // ThinClientRegion ANDs the requested true with the region's + // caching-enabled attribute, and MVP regions are proxy) skips + // cleanly. Phase 4+ flips the flag and lands the real merge. + if (hasObjects && AddToLocalCache) + { + // TODO Phase 4+ — needs: + // 1. Region.PutLocal(name, isCreate, key, value, out oldValue, + // isLocalOnly, ref updateCount, destroyTracker, versionTag) + // 2. Region.GetEntry(key, out oldValue) for the + // AddToLocalCache=false branch + // 3. GfErrType.GF_CACHE_CONCURRENT_MODIFICATION_EXCEPTION + // handling — replace Values[key] with oldValue when + // the local cache already has a higher version. + // Sketch: + // for (var index = 0; index < len; index++) { + // var key = (Keys is not null && !_hasKeys) + // ? Keys[index + KeysOffset] : localKeys[index]; + // var value = Values!.GetValueOrDefault(key); + // if (_byteArray[index] == 3) continue; // miss + // if (AddToLocalCache) { ... PutLocal ... } + // else { ... GetEntry ... } + // } + throw new NotImplementedException( + "VersionedCacheableObjectPartList.FromData step 7 (putLocal " + + "merge) pending Phase 4+ (client-side caching). " + + "Phase 1.3 should never hit this — AddToLocalCache is " + + "AND-gated against region.CachingEnabled which is false " + + "for proxy-mode MVP regions."); + } + } // end lock (_responseLock) + } + + /// + /// Construct the right subtype for the + /// current region's persistence mode. Mirrors cppcache's + /// persistent ? new DiskVersionTag(...) : new VersionTag(...) + /// dispatch + /// (cppcache/src/VersionedCacheableObjectPartList.cpp:199-235). + /// + /// + /// is now resolved through + /// DI (Scoped, registered in GeodeClientExtensions.AddCore), + /// not passed positionally — cppcache fetches it from + /// CacheImpl::m_memberListForVersionStamp per call which is + /// effectively a per-cache singleton. The earlier "pass null" + /// shape broke 's + /// ctor matcher (a runtime-null arg has no type to bind against). + /// + private VersionTag NewVersionTag(bool persistent) + { + return persistent + ? ActivatorUtilities.CreateInstance(serviceProvider) + : ActivatorUtilities.CreateInstance(serviceProvider); + } + + /// + /// Decode one (miss-flag, value) pair into + /// + / . Mirrors + /// cppcache readObjectPart + /// (cppcache/src/VersionedCacheableObjectPartList.cpp:43-90). + /// + /// + /// Three wire shapes selected by the 1-byte miss flag: + /// + /// 0 / 3 — normal entry or "key absent on + /// server" (3); ordinary object follows. + /// 2 — server-side exception for this + /// key; serialised Java blob + class name string follow. + /// Phase 1.3.c stub — + /// is NIE so this branch surfaces immediately when + /// exercised. + /// _serializeValues == true — raw bytes + /// (Java-serialised payload preserved opaque); GetAll + /// specific. + /// + /// + private void ReadObjectPart(int index, DataInput reader, object key) + { + var objType = reader.ReadByte(); + _byteArray[index] = objType; + + if (objType == 2) + { + // Exception branch (cppcache lines 50-63). Skip the Java + // exception serialised blob (length-prefixed array), read + // the class-name string, attribute it to the key. + reader.AdvanceCursor(reader.ReadArrayLen()); + var exMsg = reader.ReadString() ?? ""; + // TODO Phase 3 — NotAuthorizedException specialisation + // (cppcache differentiates "org.apache.geode.security.NotAuthorizedException" + // to throw NotAuthorizedException vs CacheServerException). + Exceptions ??= []; + Exceptions[key] = new GeodeException($"Exception at remote server: {exMsg}"); + return; + } + + if (_serializeValues) + { + // Raw-bytes branch (cppcache lines 64-82). Used by GetAll + // when the caller asked for un-deserialised payload — we + // store byte[] directly in Values. + var skipLen = reader.ReadArrayLen(); + var bytes = skipLen > 0 + ? reader.ReadBytesOnly(skipLen).ToArray() + : []; + Values![key] = bytes; + return; + } + + // Default branch (cppcache lines 83-89): dispatch through + // SerializationRegistry. Null result is a legitimate value + // for "miss" (objType==3) — store as null. + var value = serializationRegistry.ReadObject(reader); + Values![key] = value; + } + + /// + /// Merge 's entries into this instance. + /// Mirrors cppcache addAll + /// (cppcache/src/VersionedCacheableObjectPartList.hpp:185-218): + /// concatenate m_tempKeys, OR-in + /// m_regionIsVersioned, append m_versionTags. + /// + internal void AddAll(VersionedCacheableObjectPartList other) + { + ArgumentNullException.ThrowIfNull(other); + + // ── Merge keys ───────────────────────────────────────── + // cppcache wraps this in null guards for both sides because + // its m_tempKeys can be nullptr; our List is + // non-null by ctor, so a Count check suffices. Setting + // _hasKeys=true mirrors the LOGDEBUG path cppcache takes + // when _hasKeys was previously false but keys arrive. + if (other._tempKeys.Count > 0) + { + if (!_hasKeys) + { + logger.LogDebug(" VCOPL::addAll m_hasKeys should be true here"); + _hasKeys = true; + } + _tempKeys.AddRange(other._tempKeys); + } + + // ── OR-in region-versioned flag ──────────────────────── + _regionIsVersioned |= other._regionIsVersioned; + + // ── Merge version tags ───────────────────────────────── + var size = other._versionTags.Count; + logger.LogDebug(" VCOPL::addAll other->m_versionTags.size() = {Size} ", size); + if (size > 0) + { + _versionTags.AddRange(other._versionTags); + _hasTags = true; + } + } + + /// + /// Lock around concurrent fromData / addAll. + /// cppcache m_responseLock is a + /// std::recursive_mutex&; .NET equivalent is a plain + /// lock object (recursive entry by the same task is + /// not the same as recursive thread entry, but Phase 1.3's + /// chunked path drains chunks sequentially on one task, so any + /// lock suffices). Field kept for cppcache parity; not exercised + /// yet. + /// + private readonly Lock _responseLock = new(); +} + diff --git a/src/Geode.Client/Protocol/_Phase1Placeholder.cs b/src/Geode.Client/Protocol/_Phase1Placeholder.cs deleted file mode 100644 index cf59142..0000000 --- a/src/Geode.Client/Protocol/_Phase1Placeholder.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Geode.Client.Protocol; - -// Phase 1 lives here: -// - BigEndianBinaryReader / BigEndianBinaryWriter -// - TcrPart, TcrMessage records -// - IFrameCodec, TcrFrameCodec -// -// See CLAUDE.md "Protocol 三層架構" for the wire format spec extracted from -// cppcache/src/TcrMessage.{cpp,hpp}. diff --git a/src/Geode.Client/QueryExtensions.cs b/src/Geode.Client/QueryExtensions.cs new file mode 100644 index 0000000..6012489 --- /dev/null +++ b/src/Geode.Client/QueryExtensions.cs @@ -0,0 +1,62 @@ +namespace Geode.Client; + +/// +/// Convenience extensions over . +/// +public static class QueryExtensions +{ + /// + /// Execute and return the single row. Throws when the result has + /// zero or more than one row. + /// + public static async Task ExecuteSingleAsync( + this IQuery query, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + var rows = await query.ExecuteAsync(ct).ConfigureAwait(false); + return rows.Single(); + } + + /// + /// Execute and return the first row, or default when the + /// result is empty. + /// + public static async Task ExecuteFirstOrDefaultAsync( + this IQuery query, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + var rows = await query.ExecuteAsync(ct).ConfigureAwait(false); + return rows.Count == 0 ? default : rows[0]; + } + + /// + /// Replace with + /// . Returns + /// for fluent chaining. + /// + public static IQuery WithParameters( + this IQuery query, params object?[] parameters) + { + ArgumentNullException.ThrowIfNull(query); + ArgumentNullException.ThrowIfNull(parameters); + + query.Parameters.Clear(); + foreach (var p in parameters) + { + query.Parameters.Add(p); + } + return query; + } + + /// + /// Set . Returns + /// for fluent chaining. + /// + public static IQuery WithResponseTimeout( + this IQuery query, TimeSpan timeout) + { + ArgumentNullException.ThrowIfNull(query); + query.ResponseTimeout = timeout; + return query; + } +} diff --git a/src/Geode.Client/QueryStruct.cs b/src/Geode.Client/QueryStruct.cs new file mode 100644 index 0000000..35ff5c7 --- /dev/null +++ b/src/Geode.Client/QueryStruct.cs @@ -0,0 +1,71 @@ +using System.Collections; + +namespace Geode.Client; + +/// +/// A row of a multi-column OQL projection query +/// (SELECT field1, field2 FROM /region) +/// +public sealed class QueryStruct : IReadOnlyList +{ + private readonly IReadOnlyList _values; + private readonly Dictionary _fieldIndex; + + public QueryStruct(IReadOnlyList fieldNames, IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(fieldNames); + ArgumentNullException.ThrowIfNull(values); + if (fieldNames.Count != values.Count) + { + throw new ArgumentException( + $"QueryStruct field count mismatch: {fieldNames.Count} names vs {values.Count} values."); + } + + FieldNames = fieldNames; + _values = values; + + // Build name → index map. Field names in OQL projection are + // case-sensitive on the server, so ordinal comparison here. + _fieldIndex = new Dictionary(fieldNames.Count, StringComparer.Ordinal); + for (var i = 0; i < fieldNames.Count; i++) + { + _fieldIndex[fieldNames[i]] = i; + } + } + + /// Ordered field names from the OQL projection. + public IReadOnlyList FieldNames { get; } + + /// Number of fields in this row. + public int Count => _values.Count; + + /// Value at . + public object? this[int index] => _values[index]; + + /// Value of the field named . + /// + /// is not in . + /// + public object? this[string fieldName] => _values[GetFieldIndex(fieldName)]; + + /// Index of the field named . + /// + /// is not in . + /// + public int GetFieldIndex(string fieldName) + { + ArgumentNullException.ThrowIfNull(fieldName); + if (!_fieldIndex.TryGetValue(fieldName, out var idx)) + { + throw new KeyNotFoundException( + $"QueryStruct has no field named '{fieldName}'."); + } + return idx; + } + + /// Name of the field at . + public string GetFieldName(int index) => FieldNames[index]; + + public IEnumerator GetEnumerator() => _values.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/Geode.Client/RegionExistsException.cs b/src/Geode.Client/RegionExistsException.cs new file mode 100644 index 0000000..9c2e42c --- /dev/null +++ b/src/Geode.Client/RegionExistsException.cs @@ -0,0 +1,18 @@ +namespace Geode.Client; + +/// +/// Thrown by +/// when a region with the requested name is already registered on the +/// cache. Mirrors cppcache RegionExistsException +/// (cppcache/include/geode/ExceptionTypes.hpp:123). +/// +public class RegionExistsException : GeodeException +{ + public RegionExistsException() { } + + public RegionExistsException(string message) + : base(message) { } + + public RegionExistsException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/Geode.Client/RegionFactory.cs b/src/Geode.Client/RegionFactory.cs new file mode 100644 index 0000000..b427b0d --- /dev/null +++ b/src/Geode.Client/RegionFactory.cs @@ -0,0 +1,220 @@ +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Geode.Client; + +/// +/// Fluent builder for client-side region attachments; obtain via . +/// +/// +/// Mirrors cppcache RegionFactory +/// (cppcache/include/geode/RegionFactory.hpp). Setters delegate to +/// an internal ; +/// +/// snapshots the attributes so further factory mutations don't affect +/// already-built regions. +/// Listener / persistence / expiration / cacheLoader / cacheWriter +/// setters are deferred to Phase 2+. +/// +public class RegionFactory +{ + private readonly IServiceProvider _serviceProvider; + private readonly GeodeCache _cache; + private readonly RegionShortcut _shortcut; + private readonly RegionAttributesFactory _attrsFactory = new(); + + internal RegionFactory( + IServiceProvider serviceProvider, + GeodeCache cache, + RegionShortcut shortcut) + { + _serviceProvider = serviceProvider; + _cache = cache; + _shortcut = shortcut; + ApplyShortcutPresets(); + } + + /// + /// Read-only snapshot of the in-progress attributes; used by tests + /// to lock the shortcut-preset matrix. Not part of the public + /// surface — RegionAttributes itself is . + /// + internal RegionAttributes SnapshotAttributes() => _attrsFactory.Create(); + + /// The shortcut this factory was created with; test seam. + internal RegionShortcut Shortcut => _shortcut; + + /// + /// Mirrors cppcache RegionFactory::setRegionShortcut() + /// (cppcache/src/RegionFactory.cpp:60-80): the shortcut + /// pre-loads cachingEnabled and lruEntriesLimit on + /// the underlying before the + /// user gets to chain setters. Subsequent setter calls (e.g. + /// ) override these presets, + /// matching cppcache's "ctor first, setX last" precedence. + /// + private void ApplyShortcutPresets() + { + // cppcache CacheImpl.hpp:43 — DEFAULT_LRU_MAXIMUM_ENTRIES = 100000. + // No .ini / XSD entry; per dont-invent-config-knobs.md memory we + // hard-code rather than lift to Options. + const int defaultLruMaximumEntries = 100000; + + switch (_shortcut) + { + case RegionShortcut.Proxy: + _attrsFactory.SetCachingEnabled(false); + break; + case RegionShortcut.CachingProxy: + _attrsFactory.SetCachingEnabled(true); + break; + case RegionShortcut.CachingProxyEntryLru: + _attrsFactory.SetCachingEnabled(true); + _attrsFactory.SetLruEntriesLimit(defaultLruMaximumEntries); + break; + case RegionShortcut.Local: + // cppcache RegionFactory.cpp:73 — no presets; caching=true + // (RegionAttributes default) carries through. + break; + case RegionShortcut.LocalEntryLru: + _attrsFactory.SetLruEntriesLimit(defaultLruMaximumEntries); + break; + } + } + + /// Attach the region to the named ; empty falls back to the cache's default pool. + public RegionFactory SetPoolName(string poolName) + { + _attrsFactory.SetPoolName(poolName); + return this; + } + + /// Initial bucket count of the local entry map; must be positive. + public RegionFactory SetInitialCapacity(int initialCapacity) + { + _attrsFactory.SetInitialCapacity(initialCapacity); + return this; + } + + /// Load factor of the local entry map; must be positive. + public RegionFactory SetLoadFactor(float loadFactor) + { + _attrsFactory.SetLoadFactor(loadFactor); + return this; + } + + /// Concurrency level of the local entry map; must be positive. + public RegionFactory SetConcurrencyLevel(int concurrencyLevel) + { + _attrsFactory.SetConcurrencyLevel(concurrencyLevel); + return this; + } + + /// LRU cap on local entries; 0 (default) disables LRU eviction. + public RegionFactory SetLruEntriesLimit(int entriesLimit) + { + _attrsFactory.SetLruEntriesLimit(entriesLimit); + return this; + } + + /// Whether to store entries locally; means every op goes to the server. + public RegionFactory SetCachingEnabled(bool cachingEnabled) + { + _attrsFactory.SetCachingEnabled(cachingEnabled); + return this; + } + + /// Whether to clone the old value before applying a delta (default ). + public RegionFactory SetCloningEnabled(bool cloningEnabled) + { + _attrsFactory.SetCloningEnabled(cloningEnabled); + return this; + } + + /// Whether to run version checks on region entries (default ). + public RegionFactory SetConcurrencyChecksEnabled(bool concurrencyChecksEnabled) + { + _attrsFactory.SetConcurrencyChecksEnabled(concurrencyChecksEnabled); + return this; + } + + /// Build the client-side region under and register it on the cache. + /// A region with is already registered. + /// The owning cache is closed. + /// is empty or contains '/'. + /// + /// The shortcut needs a server pool and either no PoolName was set + /// (and the cache has no default pool) or the named pool is not registered. + /// + public Task> CreateAsync( + string name, CancellationToken ct = default) + where TKey : IEquatable + { + // ── Step 1. Validate name. Mirrors cppcache + // CacheImpl::createRegion (cppcache/src/CacheImpl.cpp:385-388): + // "Malformed name string, contains region path seperator '/'". + ArgumentException.ThrowIfNullOrEmpty(name); + if (name.Contains('/')) + { + throw new ArgumentException( + "Malformed name string, contains region path seperator '/'", + nameof(name)); + } + + ObjectDisposedException.ThrowIf(_cache.IsClosed, _cache); + + // ── Step 2. Snapshot attrs (cppcache + // RegionFactory.cpp:45 — m_regionAttributesFactory->create()). + var attrs = _attrsFactory.Create(); + + // ── Step 3. Auto-fill PoolName from the cache's default pool for + // shortcuts that need a server. cppcache RegionFactory.cpp:46 + // excludes ONLY RegionShortcut::LOCAL from this check; + // LocalEntryLru still needs a pool (cppcache quirk, mirrored + // for parity). + if (_shortcut != RegionShortcut.Local && string.IsNullOrEmpty(attrs.PoolName)) + { + var defaultPool = _cache.PoolManager.DefaultPool + ?? throw new InvalidOperationException("No pool for non-local region."); + // cppcache RegionFactory.cpp:52-53 writes the resolved name + // back so the region carries it on its attrs snapshot. + attrs.PoolName = ((ThinClientPoolDM)defaultPool).Name; + } + + // ── Step 4. Local shortcut needs a server-less Region impl + // (cppcache CacheImpl.cpp:526 branches to LocalRegion when + // attrs.PoolName is empty). We have no concrete LocalRegion + // yet — Phase 2+. + if (_shortcut == RegionShortcut.Local) + { + throw new NotImplementedException( + "RegionShortcut.Local needs a server-less Region implementation; Phase 2+."); + } + + // ── Step 5. Resolve the actual pool the region attaches to. + var pool = _cache.PoolManager.Find(attrs.PoolName) + ?? throw new InvalidOperationException( + $"Pool '{attrs.PoolName}' is not registered."); + var tcrPool = (ThinClientPoolDM)pool; + + // ── Step 6. Build the server-backed region. ActivatorUtilities + // so DI fills IServiceProvider + ILogger; + // we pass the per-call args (name, attrs, dm) explicitly. + var region = ActivatorUtilities.CreateInstance( + _serviceProvider, name, attrs, (ThinClientBaseDM)tcrPool); + + // ── Step 7. Register on cache (cppcache CacheImpl::createRegion + // m_regions.emplace, cppcache/src/CacheImpl.cpp:440). Throws + // RegionExistsException on duplicate name. + _cache.RegisterRegion(name, region); + + // TODO Phase 2+: cppcache CacheImpl.cpp:447-458 — when + // pool.PrSingleHopEnabled, enqueue the region's full-path with + // the pool's ClientMetadataService for initial single-hop + // metadata refresh. + + // ── Step 8. Wrap in typed view for the IRegion contract. + return Task.FromResult>( + new RegionView(region, _cache.TypedResultAdapter)); + } +} diff --git a/src/Geode.Client/RegionShortcut.cs b/src/Geode.Client/RegionShortcut.cs new file mode 100644 index 0000000..89dca92 --- /dev/null +++ b/src/Geode.Client/RegionShortcut.cs @@ -0,0 +1,36 @@ +namespace Geode.Client; + +/// +/// Predefined region attribute presets passed to +/// . +/// +public enum RegionShortcut +{ + /// + /// No local state; every operation forwards to a server. + /// + Proxy, + + /// + /// Local state plus server fallback: misses go to the server and the + /// returned value is cached locally. + /// + CachingProxy, + + /// + /// with an LRU bound on local entries + /// (default limit: 100000). + /// + CachingProxyEntryLru, + + /// + /// Local-only; never reaches a server. + /// + Local, + + /// + /// with an LRU bound on local entries + /// (default limit: 100000). + /// + LocalEntryLru, +} diff --git a/src/Geode.Client/Services/GeodeCacheFactory.cs b/src/Geode.Client/Services/GeodeCacheFactory.cs new file mode 100644 index 0000000..132321e --- /dev/null +++ b/src/Geode.Client/Services/GeodeCacheFactory.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Geode.Client.Internal; +using Geode.Client.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Geode.Client.Services; + +internal sealed class GeodeCacheFactory( + IServiceProvider rootServiceProvider, + ILogger logger) + : IGeodeCacheFactory, IAsyncDisposable +{ + + private readonly ConcurrentDictionary> _caches = new(StringComparer.Ordinal); + + private int _disposed; + + /// + public Task CreateAsync(string cacheName, CancellationToken ct = default) => + CreateAsync(cacheName, configure: null, ct); + + /// + public async Task CreateAsync( + string cacheName, + Action? configure = null, + CancellationToken ct = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + + var options = new GeodeClientOptions(); + configure?.Invoke(options, rootServiceProvider); + + var lazy = new Lazy( + () => ActivatorUtilities.CreateInstance(rootServiceProvider, cacheName, options), + LazyThreadSafetyMode.ExecutionAndPublication); + + if (!_caches.TryAdd(cacheName, lazy)) + { + throw new InvalidOperationException($"Cache '{cacheName}' already exists."); + } + + if (Volatile.Read(ref _disposed) != 0) + { + if (_caches.TryRemove(cacheName, out var stored) + && stored.IsValueCreated + && (object)stored.Value is IAsyncDisposable d) + { + await d.DisposeAsync().ConfigureAwait(false); + } + throw new ObjectDisposedException(nameof(GeodeCacheFactory)); + } + + var cache = lazy.Value; + await cache.InitializeAsync(ct).ConfigureAwait(false); + return cache; + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + + var snapshot = _caches.ToArray(); + _caches.Clear(); + + foreach (var (_, lazy) in snapshot) + { + if (lazy.IsValueCreated && (object)lazy.Value is IAsyncDisposable d) + { + await d.DisposeAsync().ConfigureAwait(false); + } + } + } + + public async ValueTask DisposeCacheAsync(string cacheName) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + + if (!_caches.TryRemove(cacheName, out var lazy)) return false; + + if (lazy.IsValueCreated && (object)lazy.Value is IAsyncDisposable d) + { + try + { + await d.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Error disposing scope for cache {CacheName}", cacheName); + } + } + return true; + } + + public IGeodeCache Get(string cacheName) + { + if (TryGet(cacheName, out var cache)) return cache; + throw new KeyNotFoundException( + $"No cache named '{cacheName}'. Call {nameof(CreateAsync)}(\"{cacheName}\") first."); + } + + public bool TryGet(string cacheName, [NotNullWhen(true)] out IGeodeCache? cache) + { + if (_caches.TryGetValue(cacheName, out var lazy)) + { + cache = lazy.Value; + return true; + } + cache = null; + return false; + } + + public IReadOnlyCollection CacheNames + { + get + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + return [.. _caches.Keys]; + } + } + +} diff --git a/tests/Geode.Client.IntegrationTests/CollectionRoundTripIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/CollectionRoundTripIntegrationTests.cs new file mode 100644 index 0000000..503b032 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/CollectionRoundTripIntegrationTests.cs @@ -0,0 +1,569 @@ +using System.Text.RegularExpressions; +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end check that the IList<T> serialisation path round-trips +/// against a live Apache Geode server. Exercises three layers in one +/// shot: +/// +/// ListDataConverter — DSCode 65 wire encode / decode. +/// SerializationRegistry open-generic dispatch — closed +/// List<int> reaches the converter via the +/// fallback. +/// TypedResultAdapter — wire-canonical +/// List<object?> is reshaped into the declared +/// TValue form (IList<int>, +/// IList<IList<string>>, …). +/// +/// Converter / adapter unit tests already cover byte-level encoding and +/// reflection branches exhaustively; this file is the integration-level +/// proof that the pieces compose correctly against a real JVM. +/// +/// +/// +/// Key range: 6000s — avoids 1000s +/// (), 2000s/3000s/4000s +/// ( round-trips), 5000s +/// ( B-route). +/// +/// +/// FreshConnectionSettleDelay carry-over from Phase 1.1 — same +/// 3s pacing as the rest of the integration suite. Each test opens its +/// own cache. +/// +/// +[Collection(nameof(GeodeCollection))] +public class CollectionRoundTripIntegrationTests(GeodeFixture fx) +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private const string RegionName = "test"; + + private async Task<(ServiceProvider Services, IRegion Region, CancellationToken Ct, CancellationTokenSource Cts)> + OpenAsync() + where TKey : IEquatable + { + var cts = new CancellationTokenSource(TestTimeout); + + var services = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .AddGeodeFactory() + .BuildServiceProvider(); + + var cache = await services.GetRequiredService().CreateAsync("c", cts.Token); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", cts.Token); + + await Task.Delay(FreshConnectionSettleDelay, cts.Token); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, cts.Token); + + return (services, region, cts.Token, cts); + } + + // ──────────────────────────────────────────────────────────── + // Round-trips + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task List_int_round_trips_through_IList_typed_view() + { + // Declared TValue = IList exercises: + // write side: open-generic fallback finds ListDataConverter + // for runtime type List. + // read side: ListDataConverter returns List; + // TypedResultAdapter materialises List; + // cast to IList succeeds. + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 6001; + var value = new List { 1, 2, 3, 4, 5 }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.IsType>(result); // adapter materialises List + Assert.Equal(value, result); + } + } + + [Fact] + public async Task List_string_round_trips() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 6002; + var value = new List { "alpha", "beta", "gamma" }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(value, result); + } + } + + [Fact] + public async Task Empty_List_round_trips_as_empty_not_null() + { + // Empty list survives as empty: wire is [DSCode 65, len 0], + // server stores an empty ArrayList, GetAsync returns an empty + // IList — distinct from null (which would be DSCode 41). + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 6003; + var value = new List(); + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Empty(result); + } + } + + [Fact] + public async Task List_string_with_null_elements_round_trips() + { + // Per-element DSCode means nulls travel as DSCode.NullObj in + // their slot; adapter materialises List with null + // preserved at the original position. + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 6004; + var value = new List { "a", null, "b", null }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(4, result!.Count); + Assert.Equal("a", result[0]); + Assert.Null(result[1]); + Assert.Equal("b", result[2]); + Assert.Null(result[3]); + } + } + + [Fact] + public async Task Nested_IList_of_IList_string_round_trips() + { + // The marquee case — nested generic target. Adapter recurses + // per element, so each inner IList is materialised + // independently. One wire ArrayList per nesting level, no + // reflection-based double walk. + var (services, region, ct, cts) = await OpenAsync>>(); + await using (services) + using (cts) + { + const int key = 6005; + var value = new List> + { + new List { "a", "b" }, + new List { "c" }, + new List(), + }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Equal(new[] { "a", "b" }, result[0]); + Assert.Equal(new[] { "c" }, result[1]); + Assert.Empty(result[2]); + } + } + + [Fact] + public async Task List_int_concrete_target_materialises_List_int() + { + // Declared TValue = List (concrete) — IsInstanceOfType + // does NOT early-out because the wire returns List; + // adapter still goes through the List materialisation path. + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 6006; + var value = new List { 10, 20, 30 }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(value, result); + } + } + + // ──────────────────────────────────────────────────────────── + // Server-side type verification (B-route via gfsh) + // + // Proves the server materialised a java.util.ArrayList from our + // wire bytes, not e.g. an object array that happens to round-trip + // symmetrically. Single B-route fact covers the encode/decode + // contract with the JVM; per-element types (Integer, String) are + // already proved by the scalar B-route tests in + // ScalarRoundTripIntegrationTests. + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task List_int_lands_as_java_ArrayList_on_server() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 6500; + var value = new List { 1, 2, 3 }; + + await region.PutAsync(key, value, ct); + + var output = await fx.GfshAsync( + $"get --region=/test --key={key} --key-class=java.lang.Integer", + ct); + + AssertMultilineMatch(output, @"^Result\s*:\s*true\s*$"); + AssertMultilineMatch( + output, + @"^Value Class\s*:\s*java\.util\.ArrayList\s*$"); + // gfsh prints ArrayList values as "[1,2,3]" — comma + // without trailing space (gfsh's own formatter, not the + // standard Java ArrayList.toString() which inserts ", "). + // The Value Class assertion above is what proves it's a + // real java.util.ArrayList; this assertion just checks + // element preservation. + AssertMultilineMatch( + output, + @"^Value\s*:\s*\[1,2,3\]\s*$"); + } + } + + // ──────────────────────────────────────────────────────────── + // Tier B-2 follow-ups: HashSet (66) / HashMap (67) / + // LinkedList (10) / Stack (74). Key range 7000s. + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task HashSet_int_round_trips_through_ISet_typed_view() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7001; + var value = new HashSet { 1, 2, 3, 4, 5 }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.IsType>(result); + // Set-equal comparison — wire iteration order is non- + // deterministic (cppcache unordered_set). + Assert.Equal(value, new HashSet(result)); + } + } + + [Fact] + public async Task HashSet_string_with_null_round_trips() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7002; + var value = new HashSet { "a", null, "b" }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Contains("a", result); + Assert.Contains(null, result); + Assert.Contains("b", result); + } + } + + [Fact] + public async Task Dictionary_int_string_round_trips_through_IDictionary_typed_view() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7003; + var value = new Dictionary + { + [1] = "alpha", + [2] = "beta", + [3] = "gamma", + }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Equal(3, result!.Count); + Assert.Equal("alpha", result[1]); + Assert.Equal("beta", result[2]); + Assert.Equal("gamma", result[3]); + } + } + + [Fact] + public async Task Dictionary_with_null_value_round_trips() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7004; + var value = new Dictionary + { + [1] = "a", + [2] = null, + [3] = "c", + }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Equal("a", result[1]); + Assert.Null(result[2]); + Assert.Equal("c", result[3]); + } + } + + [Fact] + public async Task LinkedList_int_round_trips_preserving_head_to_tail() + { + // LinkedList is NOT IList-compatible, so callers must + // declare LinkedList directly (not IList) as the typed + // view's TValue. + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7005; + var value = new LinkedList(); + value.AddLast(10); + value.AddLast(20); + value.AddLast(30); + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(new[] { 10, 20, 30 }, result); + } + } + + [Fact] + public async Task Stack_int_round_trips_preserving_push_order() + { + // The footgun test. Push 100, 200, 300 → top = 300. Round + // trip must end with the same top still on top — the wire + // reverse on write + adapter reverse on read must compose to + // identity. + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7006; + var value = new Stack(); + value.Push(100); + value.Push(200); + value.Push(300); + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Equal(300, result.Peek()); + Assert.Equal(300, result.Pop()); + Assert.Equal(200, result.Pop()); + Assert.Equal(100, result.Pop()); + } + } + + [Fact] + public async Task Nested_Dictionary_of_IList_round_trips() + { + // IDictionary> — value-side recursion through + // the adapter; each value flows ListDataConverter then + // ConvertToList. + var (services, region, ct, cts) + = await OpenAsync>>(); + await using (services) + using (cts) + { + const int key = 7007; + var value = new Dictionary> + { + [1] = new List { 10, 20 }, + [2] = new List { 30 }, + }; + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Equal(2, result!.Count); + Assert.Equal(new[] { 10, 20 }, result[1]); + Assert.Equal(new[] { 30 }, result[2]); + } + } + + // ── B-route: server-side type verification for each new DSCode ─ + + [Fact] + public async Task HashSet_int_lands_as_java_HashSet_on_server() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7500; + await region.PutAsync(key, new HashSet { 1 }, ct); + + var output = await fx.GfshAsync( + $"get --region=/test --key={key} --key-class=java.lang.Integer", + ct); + + AssertMultilineMatch(output, @"^Result\s*:\s*true\s*$"); + AssertMultilineMatch( + output, + @"^Value Class\s*:\s*java\.util\.HashSet\s*$"); + // Single-element set has deterministic value rendering; + // multi-element HashSet ordering is non-deterministic on + // Java side, so we keep the value assertion to size 1. + AssertMultilineMatch(output, @"^Value\s*:\s*\[1\]\s*$"); + } + } + + [Fact] + public async Task Dictionary_int_string_lands_as_java_HashMap_on_server() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7501; + await region.PutAsync( + key, + new Dictionary { [42] = "answer" }, + ct); + + var output = await fx.GfshAsync( + $"get --region=/test --key={key} --key-class=java.lang.Integer", + ct); + + AssertMultilineMatch(output, @"^Result\s*:\s*true\s*$"); + AssertMultilineMatch( + output, + @"^Value Class\s*:\s*java\.util\.HashMap\s*$"); + // gfsh prints HashMap with a JSON-like formatter: + // `{"key":"value"}` with double quotes around BOTH keys + // and values (regardless of their Java types — even an + // Integer key gets quoted). Not the standard Java + // HashMap.toString() form `{key=value}`. Single-entry + // map is the only deterministic case; multi-entry order + // depends on Java's bucket hashing. + AssertMultilineMatch(output, @"^Value\s*:\s*\{""42"":""answer""\}\s*$"); + } + } + + [Fact] + public async Task LinkedList_int_lands_as_java_LinkedList_on_server() + { + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7502; + var value = new LinkedList(); + value.AddLast(1); + value.AddLast(2); + value.AddLast(3); + + await region.PutAsync(key, value, ct); + + var output = await fx.GfshAsync( + $"get --region=/test --key={key} --key-class=java.lang.Integer", + ct); + + AssertMultilineMatch(output, @"^Result\s*:\s*true\s*$"); + AssertMultilineMatch( + output, + @"^Value Class\s*:\s*java\.util\.LinkedList\s*$"); + AssertMultilineMatch(output, @"^Value\s*:\s*\[1,2,3\]\s*$"); + } + } + + [Fact] + public async Task Stack_int_lands_as_java_Stack_on_server() + { + // Push 1, 2, 3 → top=3. Server's java.util.Stack toString + // iterates bottom→top via inherited Vector behaviour, so we + // expect "[1,2,3]" on the wire (gfsh no-space variant). Any + // other order would indicate a reverse-on-write bug. + var (services, region, ct, cts) = await OpenAsync>(); + await using (services) + using (cts) + { + const int key = 7503; + var value = new Stack(); + value.Push(1); + value.Push(2); + value.Push(3); + + await region.PutAsync(key, value, ct); + + var output = await fx.GfshAsync( + $"get --region=/test --key={key} --key-class=java.lang.Integer", + ct); + + AssertMultilineMatch(output, @"^Result\s*:\s*true\s*$"); + AssertMultilineMatch( + output, + @"^Value Class\s*:\s*java\.util\.Stack\s*$"); + AssertMultilineMatch(output, @"^Value\s*:\s*\[1,2,3\]\s*$"); + } + } + + private static void AssertMultilineMatch(string output, string pattern) + { + if (!Regex.IsMatch(output, pattern, RegexOptions.Multiline)) + { + Assert.Fail( + $"Pattern '{pattern}' not found in gfsh output.\n" + + $"----- gfsh stdout -----\n{output}\n----- end -----"); + } + } +} diff --git a/tests/Geode.Client.IntegrationTests/ConnectionSmokeTests.cs b/tests/Geode.Client.IntegrationTests/ConnectionSmokeTests.cs new file mode 100644 index 0000000..adc75e3 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/ConnectionSmokeTests.cs @@ -0,0 +1,74 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// Walking-skeleton end-to-end: build a cache + pool against a real Geode +/// server and verify the client at least reaches a "connected" state via +/// the background ConnManageLoop. +/// +[Collection(nameof(GeodeCollection))] +public class ConnectionSmokeTests(GeodeFixture fx) +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + [Fact] + public void Sanity_FixtureInjected() + { + // If the fixture didn't initialize, ServerPort defaults to 40404 but + // the container DID actually map ports — gfsh `version` proves the + // container is reachable. + Assert.NotNull(fx); + // After init, the host should be assigned (not the class default). + // Container hostname is something like "localhost" or a docker IP. + Assert.NotEmpty(fx.LocatorHost); + } + + [Fact] + public async Task FixtureContainer_IsActuallyRunning() + { + // Force-touch the fixture by calling gfsh inside the container. + // If the container isn't running, this throws. + var output = await fx.GfshAsync("list members", TestContext.Current.CancellationToken); + Assert.Contains("loc1", output); + } + + [Fact] + public async Task BuildAsync_AgainstRealServer_OpensConnection() + { + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + using var capture = new MeterCapture("Geode.Client.Pool", "PoolConnections"); + + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", ct); + + // ConnManageLoop opens conns fire-and-forget; poll up to 5s for the + // background RestoreMinConnections tick to land a connected endpoint. + // PoolConnections is an ObservableGauge — Observe() pulls the value. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline) + { + capture.Observe(); + if (capture.LastValue > 0) break; + await Task.Delay(100, ct); + } + + Assert.True(capture.LastValue > 0, + $"Expected PoolConnections > 0 after 5s, got {capture.LastValue}."); + } +} diff --git a/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj b/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj index 2e2c2b0..ecd5569 100644 --- a/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj +++ b/tests/Geode.Client.IntegrationTests/Geode.Client.IntegrationTests.csproj @@ -11,9 +11,9 @@ - + diff --git a/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs b/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs deleted file mode 100644 index 1234784..0000000 --- a/tests/Geode.Client.IntegrationTests/GeodeContainerSmokeTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentAssertions; -using Xunit; - -namespace Geode.Client.IntegrationTests; - -[Collection(nameof(GeodeCollection))] -public class GeodeContainerSmokeTests(GeodeFixture fx) -{ - [Fact] - public void ContainerStartsAndExposesEndpoints() - { - // Phase 0: only verifies Testcontainers + Geode image work in this env. - // Replace once Phase 2 (Ping) brings real client connectivity. - fx.LocatorPort.Should().BeGreaterThan(0); - fx.ServerPort.Should().BeGreaterThan(0); - } -} diff --git a/tests/Geode.Client.IntegrationTests/GeodeFixture.cs b/tests/Geode.Client.IntegrationTests/GeodeFixture.cs index 0ac58c9..3698147 100644 --- a/tests/Geode.Client.IntegrationTests/GeodeFixture.cs +++ b/tests/Geode.Client.IntegrationTests/GeodeFixture.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Xunit; @@ -5,8 +6,29 @@ namespace Geode.Client.IntegrationTests; /// -/// xUnit collection fixture that spins up an Apache Geode container with -/// a pre-created REPLICATE region named "test". +/// xUnit collection fixture that spins up an Apache Geode cluster in a +/// single container: 2 locators + 3 servers with a pre-created +/// REPLICATE region named "test". +/// +/// +/// The richer topology (vs. 1 locator + 1 server) lets pool / locator / +/// failover logic actually exercise the code paths it would in +/// production: locator-list refresh sees multiple peers, the pool's +/// server-endpoint list has more than one candidate, and gfsh +/// list members verifies the client really joined a cluster +/// rather than a single-process degenerate case. +/// +/// +/// +/// Host-side ports are pinned 1:1 to the container ports +/// (10334-10335 for locators, 40404-40406 for servers). Combined with +/// --hostname-for-clients=localhost on each server, this makes +/// the server address the locator hands back to clients +/// (localhost:40404, etc.) reachable from the test host — locator +/// mode end-to-end works. The downside is that at most one fixture +/// instance can run on a given host at a time; the xUnit collection +/// fixture already enforces single-instance per test run. +/// /// /// Usage: /// [Collection(nameof(GeodeCollection))] @@ -14,31 +36,115 @@ namespace Geode.Client.IntegrationTests; /// public sealed class GeodeFixture : IAsyncLifetime { + private const int Locator1ContainerPort = 10334; + private const int Locator2ContainerPort = 10335; + private const int Server1ContainerPort = 40404; + private const int Server2ContainerPort = 40405; + private const int Server3ContainerPort = 40406; + private IContainer? _container; public string LocatorHost { get; private set; } = "localhost"; - public int LocatorPort { get; private set; } = 10334; - public int ServerPort { get; private set; } = 40404; + + /// Primary (loc1) locator port — kept for backwards compat. + public int LocatorPort { get; private set; } = Locator1ContainerPort; + + /// Secondary (loc2) locator port. + public int LocatorPort2 { get; private set; } = Locator2ContainerPort; + + /// All locator ports, in start order (loc1, loc2). + public IReadOnlyList LocatorPorts { get; private set; } = + new[] { Locator1ContainerPort, Locator2ContainerPort }; + + /// Primary (srv1) server port — kept for backwards compat. + public int ServerPort { get; private set; } = Server1ContainerPort; + + /// Secondary (srv2) server port. + public int ServerPort2 { get; private set; } = Server2ContainerPort; + + /// Tertiary (srv3) server port. + public int ServerPort3 { get; private set; } = Server3ContainerPort; + + /// All server ports, in start order (srv1, srv2, srv3). + public IReadOnlyList ServerPorts { get; private set; } = + new[] { Server1ContainerPort, Server2ContainerPort, Server3ContainerPort }; + public string LocatorEndpoint => $"{LocatorHost}:{LocatorPort}"; + /// All locator endpoints ("host:port"), in start order. + public IReadOnlyList LocatorEndpoints => + LocatorPorts.Select(p => $"{LocatorHost}:{p}").ToArray(); + public async ValueTask InitializeAsync() { + // The apachegeode/geode image's default entry runs `gfsh`, which + // exits as soon as the supplied -e scripts finish — taking the + // forked locators + servers down with it. Wrap in + // `sh -c "...gfsh -e... && tail -F srv1.log"` so the container + // stays alive (and tails the first server's log to stdout for + // diagnostics). + // + // Both locators share a single `--locators=loc1,loc2` list so + // they peer-discover each other; servers join the same list. + // `--hostname-for-clients=localhost` on every member makes each + // one register a host-reachable address: servers so the + // ClientConnectionRequest reply gives the client a reachable + // server, locators so the periodic LocatorListRequest refresh + // doesn't overwrite the configured locator list with the + // container-internal IP and break subsequent locator calls. + // Combined with the fixed port mappings below. + const string locators = "localhost[10334],localhost[10335]"; _container = new ContainerBuilder() .WithImage("apachegeode/geode:latest") - .WithPortBinding(10334, true) - .WithPortBinding(40404, true) + // Pin the container's timezone so Java Date.toString() (and + // anything else reading the JVM's default zone) is + // deterministic across dev boxes / CI. Our DateTime + // converter encodes wire bytes as UTC ms-since-epoch, so + // matching the container TZ to UTC keeps server-side + // verification (gfsh `get` printing Date.toString()) stable + // and the assertion text readable. + .WithEnvironment("TZ", "UTC") + // Fixed host-port = container-port so `--hostname-for-clients=localhost` + // resolves to a port the client process on the host can reach. + // (Random host ports would defeat that — locator returns 40404, + // client tries localhost:40404, nothing there.) + .WithPortBinding(Locator1ContainerPort, Locator1ContainerPort) + .WithPortBinding(Locator2ContainerPort, Locator2ContainerPort) + .WithPortBinding(Server1ContainerPort, Server1ContainerPort) + .WithPortBinding(Server2ContainerPort, Server2ContainerPort) + .WithPortBinding(Server3ContainerPort, Server3ContainerPort) .WithCommand( - "gfsh", "-e", "start locator --name=loc --port=10334", - "-e", "start server --name=srv --server-port=40404", - "-e", "create region --name=test --type=REPLICATE") - .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(10334)) + "sh", "-c", + "mkdir -p /work && cd /work && " + + "gfsh " + + $"-e 'start locator --name=loc1 --port={Locator1ContainerPort} --hostname-for-clients=localhost --locators={locators}' " + + $"-e 'start locator --name=loc2 --port={Locator2ContainerPort} --hostname-for-clients=localhost --locators={locators}' " + + $"-e 'start server --name=srv1 --server-port={Server1ContainerPort} --hostname-for-clients=localhost --locators={locators}' " + + $"-e 'start server --name=srv2 --server-port={Server2ContainerPort} --hostname-for-clients=localhost --locators={locators}' " + + $"-e 'start server --name=srv3 --server-port={Server3ContainerPort} --hostname-for-clients=localhost --locators={locators}' " + + "-e 'create region --name=test --type=REPLICATE' " + + "&& tail -F /work/srv1/srv1.log") + // Wait until every server's client port is listening — proves + // all 3 JVMs reached the "ready for clients" state. Locator + // ports come up earlier in the chain so they're implicitly + // covered by the time the server ports are open. + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilPortIsAvailable(Server1ContainerPort) + .UntilPortIsAvailable(Server2ContainerPort) + .UntilPortIsAvailable(Server3ContainerPort)) .Build(); await _container.StartAsync(); LocatorHost = _container.Hostname; - LocatorPort = _container.GetMappedPublicPort(10334); - ServerPort = _container.GetMappedPublicPort(40404); + LocatorPort = _container.GetMappedPublicPort(Locator1ContainerPort); + LocatorPort2 = _container.GetMappedPublicPort(Locator2ContainerPort); + LocatorPorts = new[] { LocatorPort, LocatorPort2 }; + ServerPort = _container.GetMappedPublicPort(Server1ContainerPort); + ServerPort2 = _container.GetMappedPublicPort(Server2ContainerPort); + ServerPort3 = _container.GetMappedPublicPort(Server3ContainerPort); + ServerPorts = new[] { ServerPort, ServerPort2, ServerPort3 }; } public async ValueTask DisposeAsync() @@ -48,9 +154,71 @@ public async ValueTask DisposeAsync() await _container.DisposeAsync(); } } + + /// + /// Runs a gfsh command inside the running container after connecting + /// to the locator, and returns the combined stdout. + /// + /// + /// Used by integration tests to verify that what our client wrote + /// was deserialized by the server into the expected Java type — a + /// guarantee that a pure round-trip Put/Get assertion cannot make, + /// because a symmetric encoder/decoder bug would still round-trip + /// successfully while leaving the server with garbage bytes. See + /// also: discussion in PROGRESS.md Phase 1.3.0. + /// + /// + /// Output format is gfsh's tabular text (e.g. Value Class : + /// java.lang.Integer); tests parse it with Assert.Contains + /// against literal expected lines rather than building a structured + /// parser — fewer moving parts, and any gfsh format change will fail + /// loudly with a readable diff. + /// + /// + /// + /// Thrown if gfsh exits with a non-zero exit code; the captured + /// stdout + stderr are surfaced in the exception message so the + /// test failure points at the actual gfsh error (region missing, + /// type-class mismatch, etc.). + /// + public async Task GfshAsync(string command, CancellationToken ct) + { + if (_container is null) + { + throw new InvalidOperationException( + "GeodeFixture has not been initialized; call InitializeAsync first."); + } + + // -e scripts run sequentially in the same gfsh process; the + // first one connects to loc1 the container's own entry-point + // started, the second is the caller's command. Single locator + // is sufficient — gfsh learns the rest of the cluster from it. + var result = await _container.ExecAsync( + new[] + { + "gfsh", + "-e", $"connect --locator=localhost[{Locator1ContainerPort}]", + "-e", command, + }, + ct); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"gfsh '{command}' exited with code {result.ExitCode}.\n" + + $"stdout:\n{result.Stdout}\n" + + $"stderr:\n{result.Stderr}"); + } + + return result.Stdout; + } } [CollectionDefinition(nameof(GeodeCollection))] +[SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "xUnit [CollectionDefinition] uses the class name as the collection identifier; renaming away from the 'Collection' suffix would break the [Collection(nameof(GeodeCollection))] usage convention.")] public sealed class GeodeCollection : ICollectionFixture { -} +} \ No newline at end of file diff --git a/tests/Geode.Client.IntegrationTests/LocatorModeIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/LocatorModeIntegrationTests.cs new file mode 100644 index 0000000..10c2a81 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/LocatorModeIntegrationTests.cs @@ -0,0 +1,52 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// Verifies the locator-mode background refresh loop fires against a +/// real locator. cppcache ThinClientPoolDM::m_updateLocatorListTask +/// equivalent: every UpdateLocatorListInterval tick the pool +/// sends a LocatorListRequest RPC to learn newly-added locators +/// and drop dead ones; elapsed time is recorded in the +/// LocatorListRequestTime Histogram. +/// +[Collection(nameof(GeodeCollection))] +public class LocatorModeIntegrationTests(GeodeFixture fx) +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task LocatorUpdateLoop_FiresPeriodically() + { + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + using var capture = new MeterCapture("Geode.Client.Pool", "LocatorListRequestTime"); + + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddLocator(fx.LocatorHost, fx.LocatorPort) + .SetUpdateLocatorListInterval(TimeSpan.FromMilliseconds(500)) + .BuildAsync("p", ct); + + // LocatorListRequestTime is a Histogram; Count records each RPC. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline && capture.Count == 0) + { + await Task.Delay(100, ct); + } + + Assert.True(capture.Count > 0, + $"Expected LocatorListRequestTime to record ≥1 RPC within 10s, got Count={capture.Count}."); + } +} diff --git a/tests/Geode.Client.IntegrationTests/MeterCapture.cs b/tests/Geode.Client.IntegrationTests/MeterCapture.cs new file mode 100644 index 0000000..5430f2b --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/MeterCapture.cs @@ -0,0 +1,80 @@ +using System.Diagnostics.Metrics; + +namespace Geode.Client.IntegrationTests; + +/// +/// Test helper that listens on a single named instrument and tracks the +/// number of measurements + running sum + last value. Handles +/// long, double, and int instruments — push (Counter, +/// Histogram) fire on their own; pull (ObservableGauge, ObservableCounter, +/// ObservableUpDownCounter) fire when is called. +/// Used to assert PoolStatistics instruments fire on the expected +/// code paths without exposing implementation-side snapshot properties. +/// +internal sealed class MeterCapture : IDisposable +{ + private readonly MeterListener _listener = new(); + private long _count; + private double _sum; + private double _lastValue; + private readonly Lock _sumLock = new(); + + public MeterCapture(string meterName, string instrumentName) + { + _listener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == meterName && instrument.Name == instrumentName) + { + listener.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback( + (_, value, _, _) => Record(value)); + _listener.SetMeasurementEventCallback( + (_, value, _, _) => Record(value)); + _listener.SetMeasurementEventCallback( + (_, value, _, _) => Record(value)); + _listener.Start(); + } + + public long Count => Interlocked.Read(ref _count); + + public double Sum + { + get + { + lock (_sumLock) return _sum; + } + } + + /// + /// Last measurement value seen. For ObservableGauge instruments this + /// reflects the most recent invocation. + /// + public double LastValue + { + get + { + lock (_sumLock) return _lastValue; + } + } + + /// + /// Pull current values from any subscribed Observable* instruments. + /// Push instruments (Counter, Histogram) ignore this — they fire on + /// Add / Record at their own call site. + /// + public void Observe() => _listener.RecordObservableInstruments(); + + private void Record(double value) + { + Interlocked.Increment(ref _count); + lock (_sumLock) + { + _sum += value; + _lastValue = value; + } + } + + public void Dispose() => _listener.Dispose(); +} \ No newline at end of file diff --git a/tests/Geode.Client.IntegrationTests/PingIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/PingIntegrationTests.cs new file mode 100644 index 0000000..dc7151b --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/PingIntegrationTests.cs @@ -0,0 +1,53 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// Verifies the background ping loop fires against a real Apache Geode +/// server. cppcache ThinClientPoolDM::m_pingTask equivalent: +/// once the pool has at least one connected endpoint, the loop sweeps +/// it on each PingInterval tick and records elapsed time in the +/// PingSweepTime Histogram. +/// +[Collection(nameof(GeodeCollection))] +public class PingIntegrationTests(GeodeFixture fx) +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task PingLoop_FiresPeriodically() + { + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + using var capture = new MeterCapture("Geode.Client.Pool", "PingSweepTime"); + + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .SetPingInterval(TimeSpan.FromMilliseconds(500)) + .BuildAsync("p", ct); + + // PingSweepTime is a Histogram; Count records each sweep. Poll up + // to 10s for at least one tick to land. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < deadline && capture.Count == 0) + { + await Task.Delay(100, ct); + } + + Assert.True(capture.Count > 0, + $"Expected PingSweepTime to record ≥1 sweep within 10s, got Count={capture.Count}."); + } +} diff --git a/tests/Geode.Client.IntegrationTests/RegionClearInvalidateRemoveIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/RegionClearInvalidateRemoveIntegrationTests.cs new file mode 100644 index 0000000..99f1073 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/RegionClearInvalidateRemoveIntegrationTests.cs @@ -0,0 +1,137 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end checks for the three Region mutation ops that don't have +/// their own integration file yet: , +/// , +/// . +/// Key range 7000s to stay clear of other suites. +/// +[Collection(nameof(GeodeCollection))] +public class RegionClearInvalidateRemoveIntegrationTests(GeodeFixture fx) +{ + private const string RegionName = "test"; + + /// cppcache geode-fresh-conn-race.md mitigation — 3s buffer. + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private async Task> BuildRegionAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", ct); + + await Task.Delay(FreshConnectionSettleDelay, ct); + + return await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, ct); + } + + // ── Remove ──────────────────────────────────────────────────── + + [Fact] + public async Task RemoveAsync_PrePutKey_ReturnsTrue() + { + var ct = TestContext.Current.CancellationToken; + const int key = 7001; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value=v --value-class=java.lang.String", + ct); + + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.True(await region.RemoveAsync(key, ct)); + // Sanity: re-removing reports false (no cleanup needed in finally). + Assert.False(await region.RemoveAsync(key, ct)); + } + + [Fact] + public async Task RemoveAsync_AbsentKey_ReturnsFalse() + { + // cppcache TcrMessage.cpp:1317 — reply's last part i32 entryNotFound=1. + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.False(await region.RemoveAsync(7002, ct)); + } + + // ── Invalidate ──────────────────────────────────────────────── + + [Fact] + public async Task InvalidateAsync_PrePutKey_KeyRetained_ValueGone() + { + // After invalidate, server still has the key (ContainsKey=true) + // but Get returns null. cppcache parity: + // ThinClientRegion::invalidateNoThrow_remote success path. + var ct = TestContext.Current.CancellationToken; + const int key = 7003; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value=v --value-class=java.lang.String", + ct); + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + await region.InvalidateAsync(key, ct); + + Assert.True(await region.ContainsKeyAsync(key, ct)); // key still present + Assert.Null(await region.GetAsync(key, ct)); // value null + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + + // ── Clear ───────────────────────────────────────────────────── + + [Fact] + public async Task ClearAsync_RemovesAllEntries() + { + // Pre-populate two keys via gfsh; client Clear; verify both are + // gone (Get returns null). cppcache parity: server-side full + // region clear, no version-tag handling Phase 1.x. + var ct = TestContext.Current.CancellationToken; + const int keyA = 7004; + const int keyB = 7005; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={keyA} --key-class=java.lang.Integer --value=a --value-class=java.lang.String", + ct); + await fx.GfshAsync( + $"put --region=/{RegionName} --key={keyB} --key-class=java.lang.Integer --value=b --value-class=java.lang.String", + ct); + + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + await region.ClearAsync(ct); + + Assert.Null(await region.GetAsync(keyA, ct)); + Assert.Null(await region.GetAsync(keyB, ct)); + } +} diff --git a/tests/Geode.Client.IntegrationTests/RegionContainsKeyIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/RegionContainsKeyIntegrationTests.cs new file mode 100644 index 0000000..d941e40 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/RegionContainsKeyIntegrationTests.cs @@ -0,0 +1,95 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end check for against a +/// real Apache Geode server: the unit tests lock the wire layout, this +/// file exercises the full call chain +/// ( +/// → wire frame → server reply → bool decode). Keys are pre-populated +/// via gfsh put because PutAsync isn't implemented yet. +/// +/// +/// Key range 9000s picked to stay clear of other integration +/// suites' ranges. Tests in the shared +/// run sequentially so the pre-put / remove pattern is race-free. +/// +[Collection(nameof(GeodeCollection))] +public class RegionContainsKeyIntegrationTests(GeodeFixture fx) +{ + private const string RegionName = "test"; + + /// + /// Cold-container ClientHealthMonitor registration race + /// (geode-fresh-conn-race.md): the server takes 100ms–3s to + /// register a fresh client connection. First op without this buffer + /// surfaces as a RegionDestroyedException. + /// + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private async Task> BuildRegionAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", ct); + + await Task.Delay(FreshConnectionSettleDelay, ct); + + return await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, ct); + } + + [Fact] + public async Task ContainsKeyAsync_AbsentKey_ReturnsFalse() + { + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + // 9001 belongs to a key range we never populate via gfsh. + Assert.False(await region.ContainsKeyAsync(9001, ct)); + } + + [Fact] + public async Task ContainsKeyAsync_PrePutKey_ReturnsTrue() + { + var ct = TestContext.Current.CancellationToken; + const int key = 9002; + + // Pre-populate via gfsh — PutAsync isn't implemented (Phase 1.x). + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value=hello --value-class=java.lang.String", + ct); + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.True(await region.ContainsKeyAsync(key, ct)); + } + finally + { + // Deterministic cleanup so re-runs against the same fixture + // don't carry residue keys. + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } +} diff --git a/tests/Geode.Client.IntegrationTests/RegionExistsSelectValueIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/RegionExistsSelectValueIntegrationTests.cs new file mode 100644 index 0000000..5ed79fa --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/RegionExistsSelectValueIntegrationTests.cs @@ -0,0 +1,127 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end checks for the two OQL-backed region convenience methods: +/// and +/// . +/// Wire path: ThinClientRegion.QueryAsync → RemoteQueryService → +/// RemoteQuery → MessageType.Query (or QueryWithParameters) → +/// ChunkedQueryResponse. +/// Key range 5000s. +/// +[Collection(nameof(GeodeCollection))] +public class RegionExistsSelectValueIntegrationTests(GeodeFixture fx) +{ + private const string RegionName = "test"; + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private async Task> BuildRegionAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", ct); + await Task.Delay(FreshConnectionSettleDelay, ct); + return await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, ct); + } + + // ── ExistsValueAsync ────────────────────────────────────────── + + [Fact] + public async Task ExistsValueAsync_PrePutMatch_ReturnsTrue() + { + // Pre-put a row, then query for any row where value=hit. + // QueryAsync wraps the predicate as: + // select distinct * from /test this where this='hit' + // matching cppcache Region::query (ThinClientRegion.cpp:524-535). + var ct = TestContext.Current.CancellationToken; + const int key = 5001; + const string value = "hit"; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value={value} --value-class=java.lang.String", + ct); + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.True(await region.ExistsValueAsync($"this='{value}'", ct)); + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + + [Fact] + public async Task ExistsValueAsync_NoMatch_ReturnsFalse() + { + // No setup; predicate matches no rows in the region. + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.False(await region.ExistsValueAsync("this='no-such-value-5002'", ct)); + } + + // ── SelectValueAsync ────────────────────────────────────────── + + [Fact] + public async Task SelectValueAsync_SingleMatch_ReturnsValue() + { + // SelectValue contract: 0 matches → null, 1 match → that value, + // >1 match → throw. Phase 1.x scope locks the 1-match path here. + var ct = TestContext.Current.CancellationToken; + const int key = 5003; + const string value = "unique-5003"; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value={value} --value-class=java.lang.String", + ct); + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + var result = await region.SelectValueAsync($"this='{value}'", ct); + + Assert.Equal(value, result); + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + + [Fact] + public async Task SelectValueAsync_NoMatch_ReturnsNull() + { + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.Null(await region.SelectValueAsync("this='no-such-value-5004'", ct)); + } +} diff --git a/tests/Geode.Client.IntegrationTests/RegionPutAllRemoveAllGetAllIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/RegionPutAllRemoveAllGetAllIntegrationTests.cs new file mode 100644 index 0000000..59abbdb --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/RegionPutAllRemoveAllGetAllIntegrationTests.cs @@ -0,0 +1,183 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end checks for the three bulk-op variants: +/// , +/// , +/// . +/// Wire layout is unit-locked; integration exercises the +/// chunked-reply pipeline against real Apache Geode. +/// Key range 6000s. +/// +[Collection(nameof(GeodeCollection))] +public class RegionPutAllRemoveAllGetAllIntegrationTests(GeodeFixture fx) +{ + private const string RegionName = "test"; + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private async Task> BuildRegionAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", ct); + await Task.Delay(FreshConnectionSettleDelay, ct); + return await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, ct); + } + + // ── PutAll ──────────────────────────────────────────────────── + + [Fact] + public async Task PutAllAsync_ThreeEntries_AllVisibleOnServer() + { + var ct = TestContext.Current.CancellationToken; + var map = new Dictionary + { + [6001] = "a", + [6002] = "b", + [6003] = "c", + }; + + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + try + { + await region.PutAllAsync(map, ct); + + // Cross-check each key via client Get (round-trip through wire layer). + Assert.Equal("a", await region.GetAsync(6001, ct)); + Assert.Equal("b", await region.GetAsync(6002, ct)); + Assert.Equal("c", await region.GetAsync(6003, ct)); + } + finally + { + foreach (var key in map.Keys) + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + } + + // ── RemoveAll ───────────────────────────────────────────────── + + [Fact] + public async Task RemoveAllAsync_ThreePreputKeys_AllGone() + { + var ct = TestContext.Current.CancellationToken; + var keys = new object[] { 6011, 6012, 6013 }; + + foreach (var key in keys) + { + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value=v --value-class=java.lang.String", + ct); + } + + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + await region.RemoveAllAsync(keys, ct); + + foreach (var key in keys) + { + Assert.Null(await region.GetAsync((int)key, ct)); + } + } + + // ── GetAll ──────────────────────────────────────────────────── + + [Fact] + public async Task GetAllAsync_GfshPrePut_ReturnsAllValues() + { + var ct = TestContext.Current.CancellationToken; + var expected = new Dictionary + { + [6021] = "alpha", + [6022] = "beta", + [6023] = "gamma", + }; + + foreach (var (key, value) in expected) + { + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value={value} --value-class=java.lang.String", + ct); + } + + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + var result = await region.GetAllAsync(expected.Keys.Cast().ToArray(), ct); + + Assert.Equal(expected.Count, result.Count); + foreach (var (key, value) in expected) + { + Assert.Equal(value, result[key]); + } + } + finally + { + foreach (var key in expected.Keys) + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + } + + [Fact] + public async Task GetAllAsync_MixedPresentAbsent_NullsForAbsent() + { + // Pre-put one of three keys; GetAll returns null for the two + // absent ones (cppcache m_byteArray[i]==3 stores null). + var ct = TestContext.Current.CancellationToken; + const int presentKey = 6031; + const string value = "present"; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={presentKey} --key-class=java.lang.Integer --value={value} --value-class=java.lang.String", + ct); + + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + var result = await region.GetAllAsync( + new object[] { presentKey, 6032, 6033 }, ct); + + Assert.Equal(value, result[presentKey]); + Assert.Null(result[6032]); + Assert.Null(result[6033]); + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={presentKey} --key-class=java.lang.Integer", + ct); + } + } +} diff --git a/tests/Geode.Client.IntegrationTests/RegionPutGetIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/RegionPutGetIntegrationTests.cs new file mode 100644 index 0000000..bd20106 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/RegionPutGetIntegrationTests.cs @@ -0,0 +1,156 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end Put + Get round-trip against a real Apache Geode server. +/// Client-only round-trip (Put → Get) checks the encode/decode pair; +/// gfsh cross-checks confirm the server actually parsed our bytes +/// rather than just symmetrically echoing them back (memory note in +/// PROGRESS.md Phase 1.3.0). +/// +/// +/// Key range 8000s picked to stay clear of the ContainsKey +/// suite's 9000s and other ranges. Tests in the shared +/// run sequentially so put / remove +/// pattern is race-free. +/// +[Collection(nameof(GeodeCollection))] +public class RegionPutGetIntegrationTests(GeodeFixture fx) +{ + private const string RegionName = "test"; + + /// cppcache geode-fresh-conn-race.md mitigation — 3s buffer for ClientHealthMonitor. + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private async Task> BuildRegionAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", ct); + + await Task.Delay(FreshConnectionSettleDelay, ct); + + return await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, ct); + } + + // ── Client-only round-trip ──────────────────────────────────── + + [Fact] + public async Task PutThenGet_RoundTripsValue() + { + var ct = TestContext.Current.CancellationToken; + const int key = 8001; + const string value = "round-trip-payload"; + + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + try + { + await region.PutAsync(key, value, ct); + var got = await region.GetAsync(key, ct); + + Assert.Equal(value, got); + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + + [Fact] + public async Task Put_PersistsOnServer_VisibleViaGfsh() + { + // gfsh cross-check: prove the server deserialized our wire bytes + // into the expected Java types (Integer key, String value) rather + // than just round-tripping opaque bytes. + var ct = TestContext.Current.CancellationToken; + const int key = 8002; + const string value = "hello-gfsh"; + + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + try + { + await region.PutAsync(key, value, ct); + + var gfshOutput = await fx.GfshAsync( + $"get --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + + Assert.Contains(value, gfshOutput); + Assert.Contains("java.lang.String", gfshOutput); + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } + + // ── Get cache-miss path ────────────────────────────────────── + + [Fact] + public async Task GetAsync_MissingKey_ReturnsNull() + { + // cppcache readObjectPart empty branch: server returns Response + // with single Part(IsObject=0, payloadLength=0) when the key is + // absent; DecodeValuePart maps that to null. + var ct = TestContext.Current.CancellationToken; + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.Null(await region.GetAsync(8003, ct)); + } + + // ── Get of gfsh-prepopulated value ─────────────────────────── + + [Fact] + public async Task GetAsync_GfshPrePut_DecodesValue() + { + // Reverse cross-check: server populates, client decodes. Catches + // SerializationRegistry-side decoder regressions that a client- + // only round-trip would miss (symmetric encode/decode bug). + var ct = TestContext.Current.CancellationToken; + const int key = 8004; + const string value = "gfsh-prepopulated"; + + await fx.GfshAsync( + $"put --region=/{RegionName} --key={key} --key-class=java.lang.Integer --value={value} --value-class=java.lang.String", + ct); + try + { + await using var sp = BuildSp(); + var region = await BuildRegionAsync(sp, ct); + + Assert.Equal(value, await region.GetAsync(key, ct)); + } + finally + { + await fx.GfshAsync( + $"remove --region=/{RegionName} --key={key} --key-class=java.lang.Integer", + ct); + } + } +} diff --git a/tests/Geode.Client.IntegrationTests/ScalarRoundTripIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/ScalarRoundTripIntegrationTests.cs new file mode 100644 index 0000000..d21b48d --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/ScalarRoundTripIntegrationTests.cs @@ -0,0 +1,606 @@ +using System.Text.RegularExpressions; +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// Phase 1.3.0 end-to-end check that every newly-registered built-in +/// scalar / converter round-trips correctly +/// against a live Apache Geode server. The scope is "the wire layer +/// I just touched still works once the bytes reach a real JVM" — +/// converter unit tests already exhaustively cover the byte-level +/// encoding; this file is the integration-level smoke check on top. +/// +/// +/// One typed view per value type, +/// each test does Put → Get and asserts equality. Keys live in +/// distinct ranges so the tests can run in any order against the +/// same shared region. +/// +/// +/// +/// +/// Key range: 2000s in int column (this file) — picked +/// to avoid collision with +/// 's 1000s and 0x7FFF_xxxx +/// ranges. The long-key test uses a value outside the +/// int range entirely. +/// +/// +/// FreshConnectionSettleDelay carry-over from Phase 1.1 — see +/// for context. Each test +/// opens its own cache and pays the 3s delay; total file run-time is +/// dominated by that, not by the wire ops. +/// +/// +[Collection(nameof(GeodeCollection))] +public class ScalarRoundTripIntegrationTests(GeodeFixture fx) +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + + private const string RegionName = "test"; + + private async Task<(ServiceProvider Services, IRegion Region, CancellationToken Ct, CancellationTokenSource Cts)> + OpenAsync() + where TKey : IEquatable + { + var cts = new CancellationTokenSource(TestTimeout); + + var services = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .AddGeodeFactory() + .BuildServiceProvider(); + + var cache = await services.GetRequiredService().CreateAsync("c", cts.Token); + await cache.PoolManager.CreateFactory() + .AddServer(fx.LocatorHost, fx.ServerPort) + .SetMinConnections(1) + .BuildAsync("p", cts.Token); + + await Task.Delay(FreshConnectionSettleDelay, cts.Token); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, cts.Token); + + return (services, region, cts.Token, cts); + } + + // ──────────────────────────────────────────────────────────── + // Server-side type verification (B-route: gfsh bypass read) + // + // Round-trip Put/Get cannot prove the server understood our wire + // bytes — a symmetric encoder/decoder bug round-trips fine while + // the server stores garbage. These tests Put via our client, then + // query the server through gfsh and assert the Java class + value + // the server actually materialized. See PROGRESS.md Phase 1.3.0 + // for the full rationale; this single int probe is the prototype + // before extending to other types. + // ──────────────────────────────────────────────────────────── + + // 5000s range reserved for B-route server-side checks so they + // don't collide with the 2000s/3000s/4000s round-trip tests below. + + /// + /// Puts under + /// via our client, then runs gfsh get against the same key + /// and asserts the server's Value Class is exactly + /// and Value renders as + /// . This proves the server + /// deserialized the bytes we sent into the intended Java type with + /// the intended value — a guarantee Put/Get round-trip cannot make + /// because a symmetric encoder/decoder bug round-trips fine. + /// + private async Task VerifyServerSideAsync( + int intKey, + TValue value, + string expectedJavaClass, + string expectedJavaToString) + where TValue : notnull + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + await region.PutAsync(intKey, value, ct); + + var output = await fx.GfshAsync( + $"get --region=/test --key={intKey} --key-class=java.lang.Integer", + ct); + + // gfsh's `get` output looks like: + // Result : true + // Key Class : java.lang.Integer + // Key : 5001 + // Value Class : java.lang.Integer + // Value : 42 + // + // Tight regex matches rooted on the labels + Multiline + // option so `$` anchors to end-of-line — without that, + // "java.lang.Integer" appearing mid-output won't satisfy + // `\s*$` because more lines follow. + AssertMultilineMatch(output, @"^Result\s*:\s*true\s*$"); + AssertMultilineMatch( + output, + $@"^Value Class\s*:\s*{Regex.Escape(expectedJavaClass)}\s*$"); + AssertMultilineMatch( + output, + $@"^Value\s*:\s*{Regex.Escape(expectedJavaToString)}\s*$"); + } + } + + /// + /// with + /// enabled (so ^ and + /// $ anchor to line boundaries, not just string boundaries) + /// and a failure message that dumps the full gfsh output. The + /// dump matters: when a test fails we want to see the actual + /// tabular output once, not have to add ad-hoc Console.WriteLine + /// and re-run. + /// + private static void AssertMultilineMatch(string output, string pattern) + { + if (!Regex.IsMatch(output, pattern, RegexOptions.Multiline)) + { + Assert.Fail( + $"Pattern '{pattern}' not found in gfsh output.\n" + + $"----- gfsh stdout -----\n{output}\n----- end -----"); + } + } + + [Fact] + public Task Int32_value_lands_as_java_Integer_on_server() + => VerifyServerSideAsync(5001, 42, "java.lang.Integer", "42"); + + [Fact] + public Task Boolean_value_lands_as_java_Boolean_on_server() + => VerifyServerSideAsync(5002, true, "java.lang.Boolean", "true"); + + [Fact] + public Task Character_value_lands_as_java_Character_on_server() + // CJK char to also smoke-test UTF-8 on the gfsh stdout path. + // gfsh wraps both Character and String values in double quotes + // for display (a presentation choice, not part of the value); + // the Value Class assertion is what proves it's really a + // java.lang.Character on the server, not a java.lang.String. + => VerifyServerSideAsync(5003, '中', "java.lang.Character", "\"中\""); + + [Fact] + public Task Byte_value_lands_as_java_Byte_on_server() + // .NET byte 0x80 = 128 unsigned ↔ Java byte -128 signed. + // gfsh prints Java's signed toString, so the assertion is "-128". + => VerifyServerSideAsync(5004, (byte)0x80, "java.lang.Byte", "-128"); + + [Fact] + public Task Int16_value_lands_as_java_Short_on_server() + => VerifyServerSideAsync(5005, short.MaxValue, "java.lang.Short", "32767"); + + [Fact] + public Task Int64_value_lands_as_java_Long_on_server() + => VerifyServerSideAsync( + 5006, + long.MaxValue, + "java.lang.Long", + "9223372036854775807"); + + [Fact] + public Task Single_value_lands_as_java_Float_on_server() + // Float.toString(3.14f) in Java prints exactly "3.14". + => VerifyServerSideAsync(5007, 3.14f, "java.lang.Float", "3.14"); + + [Fact] + public Task Double_value_lands_as_java_Double_on_server() + // Double.toString(Math.PI) in Java prints "3.141592653589793" + // (same 17-digit shortest-round-trip as .NET's "G17" / default). + => VerifyServerSideAsync( + 5008, + Math.PI, + "java.lang.Double", + "3.141592653589793"); + + [Fact] + public Task DateTime_value_lands_as_java_Date_on_server() + { + // 2026-05-12 14:30:45.123 UTC. Empirically gfsh prints + // java.util.Date as the raw ms-since-epoch long, not as + // Date.toString(), which is actually a stronger check than + // EEE MMM dd HH:mm:ss zzz yyyy because it pins the + // millisecond field too (Date.toString truncates to seconds). + // + // Days 1970-01-01 .. 2026-05-12 UTC = 20585 + // 20585 * 86400 + 14*3600 + 30*60 + 45 = 1 778 596 245 s + // * 1000 + 123 ms = 1 778 596 245 123 + var value = new DateTime(2026, 5, 12, 14, 30, 45, 123, DateTimeKind.Utc); + return VerifyServerSideAsync( + 5010, + value, + "java.util.Date", + "1778596245123"); + } + + // ──────────────────────────────────────────────────────────── + // String — one B-route fact per DSCode variant. Each variant is + // its own encoder code path (ASCII vs modified-UTF-8 short, plus + // the huge variants that switch to a 4-byte length + the + // non-ASCII huge case that swaps modified-UTF-8 out for UTF-16 + // BE), so a single ASCII-short check would silently bless the + // three untested encoders. + // ──────────────────────────────────────────────────────────── + + [Fact] + public Task String_ascii_value_lands_as_java_String_on_server() + // DSCode 87 (CacheableASCIIString): ASCII content + ≤65535 bytes. + // gfsh wraps String values in double quotes for display + // (see Character_value_lands_as_java_Character_on_server). + => VerifyServerSideAsync( + 5009, + "Hello, Geode!", + "java.lang.String", + "\"Hello, Geode!\""); + + [Fact] + public Task String_non_ascii_value_lands_as_java_String_on_server() + // DSCode 42 (CacheableString): non-ASCII content + modified-UTF-8 + // byte length ≤65535. Mix Latin extended + CJK to exercise + // 2-byte and 3-byte modified-UTF-8 sequences in one go. + => VerifyServerSideAsync( + 5011, + "中文 mixed Aé 你好", + "java.lang.String", + "\"中文 mixed Aé 你好\""); + + [Fact] + public Task String_huge_ascii_value_lands_as_java_String_on_server() + { + // DSCode 88 (CacheableASCIIStringHuge): ASCII + >65535 chars, + // length-prefix switches from u16 to i32. + var value = new string('x', 70000); + return VerifyServerSideAsync( + 5012, + value, + "java.lang.String", + "\"" + value + "\""); + } + + [Fact] + public Task String_huge_non_ascii_value_lands_as_java_String_on_server() + { + // DSCode 89 (CacheableStringHuge): non-ASCII + modified-UTF-8 + // length would exceed 65535, so cppcache deliberately switches + // the encoding to UTF-16 BE with a u32 char-count length + // prefix. This is the most uniquely-shaped path in the whole + // string converter — UTF-16 BE on the wire, every other code + // path uses modified-UTF-8. + // + // 35000 × '中' = 105000 modified-UTF-8 bytes (would overflow + // u16), but only 70000 UTF-16 bytes — fits cleanly. + var value = new string('中', 35000); + return VerifyServerSideAsync( + 5013, + value, + "java.lang.String", + "\"" + value + "\""); + } + + // ──────────────────────────────────────────────────────────── + // Value-side round-trips (int key, varying value type) + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Bool_value_round_trips() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2001; + + await region.PutAsync(key, true, ct); + Assert.True(await region.GetAsync(key, ct)); + + await region.PutAsync(key, false, ct); + Assert.False(await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task Char_value_round_trips() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2002; + + // CJK char to exercise the full UTF-16 code-unit width. + await region.PutAsync(key, '中', ct); + Assert.Equal('中', await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task Byte_value_round_trips_across_signed_boundary() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2003; + + // 0x80 = .NET 128 = Java -128: the boundary where signed / + // unsigned interpretations diverge. Wire bit-pattern must + // survive intact regardless. + await region.PutAsync(key, (byte)0x80, ct); + Assert.Equal((byte)0x80, await region.GetAsync(key, ct)); + + // And 0xFF (.NET 255, Java -1) for completeness. + await region.PutAsync(key, (byte)0xFF, ct); + Assert.Equal((byte)0xFF, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task Int16_value_round_trips_including_negative() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2004; + + await region.PutAsync(key, short.MinValue, ct); + Assert.Equal(short.MinValue, await region.GetAsync(key, ct)); + + await region.PutAsync(key, short.MaxValue, ct); + Assert.Equal(short.MaxValue, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task Int64_value_round_trips_including_negative() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2005; + + await region.PutAsync(key, long.MinValue, ct); + Assert.Equal(long.MinValue, await region.GetAsync(key, ct)); + + await region.PutAsync(key, long.MaxValue, ct); + Assert.Equal(long.MaxValue, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task Single_value_round_trips_including_nan_and_infinity() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2006; + + await region.PutAsync(key, 3.14159f, ct); + Assert.Equal(3.14159f, await region.GetAsync(key, ct)); + + await region.PutAsync(key, float.PositiveInfinity, ct); + Assert.Equal(float.PositiveInfinity, await region.GetAsync(key, ct)); + + await region.PutAsync(key, float.NaN, ct); + Assert.True(float.IsNaN(await region.GetAsync(key, ct))); + } + } + + [Fact] + public async Task Double_value_round_trips_including_nan_and_infinity() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2007; + + await region.PutAsync(key, Math.PI, ct); + Assert.Equal(Math.PI, await region.GetAsync(key, ct)); + + await region.PutAsync(key, double.NegativeInfinity, ct); + Assert.Equal(double.NegativeInfinity, await region.GetAsync(key, ct)); + + await region.PutAsync(key, double.NaN, ct); + Assert.True(double.IsNaN(await region.GetAsync(key, ct))); + } + } + + [Fact] + public async Task DateTime_value_round_trips_as_utc() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 2008; + + // Pick a millisecond-aligned UTC instant so encode's + // truncate-to-ms doesn't mask a real bug. + var input = new DateTime(2026, 5, 12, 14, 30, 45, 123, DateTimeKind.Utc); + + await region.PutAsync(key, input, ct); + var result = await region.GetAsync(key, ct); + + Assert.Equal(input, result); + Assert.Equal(DateTimeKind.Utc, result.Kind); + } + } + + // ──────────────────────────────────────────────────────────── + // String value — exercises all four CacheableString DSCode variants + // (ASCII short / ASCII huge / mod-UTF-8 short / UTF-16 huge) + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task String_ascii_value_round_trips() + { + // ASCII content + length ≤ 65535 → DSCode 87 (CacheableASCIIString). + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 3001; + const string value = "Hello, Geode!"; + + await region.PutAsync(key, value, ct); + Assert.Equal(value, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task String_non_ascii_value_round_trips_via_modified_utf8() + { + // Non-ASCII content (CJK + accented) + modified-UTF-8 byte length + // well under 65535 → DSCode 42 (CacheableString). Verifies the + // round-trip survives \0, modified-UTF-8 encoding, and the + // surrogate-pair handling for the emoji. + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 3002; + const string value = "中文 mixed Aé 你好\0 \U0001F600"; + + await region.PutAsync(key, value, ct); + Assert.Equal(value, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task String_huge_ascii_value_round_trips() + { + // > 65535 chars, all ASCII → DSCode 88 (CacheableASCIIStringHuge). + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 3003; + var value = new string('x', 70000); + + await region.PutAsync(key, value, ct); + Assert.Equal(value, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task String_huge_non_ascii_value_round_trips_via_utf16() + { + // 35000 × '中' = 105000 modified-UTF-8 bytes > 65535 → encoding + // switches to DSCode 89 (CacheableStringHuge, UTF-16 BE). + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 3004; + var value = new string('中', 35000); + + await region.PutAsync(key, value, ct); + Assert.Equal(value, await region.GetAsync(key, ct)); + } + } + + // ──────────────────────────────────────────────────────────── + // byte[] value — VL-encoded length (inline / u16 / i32) + raw bytes + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Bytes_value_round_trips() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 4001; + var value = new byte[] { 0x00, 0x7F, 0x80, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF }; + + await region.PutAsync(key, value, ct); + Assert.Equal(value, await region.GetAsync(key, ct)); + } + } + + [Fact] + public async Task Bytes_empty_value_round_trips_as_empty_not_null() + { + // Distinct from null: byte[0] writes [46, 0x00] (DSCode + VL + // length 0), Get returns a zero-length array, not null. + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 4002; + var value = Array.Empty(); + + await region.PutAsync(key, value, ct); + var result = await region.GetAsync(key, ct); + + Assert.NotNull(result); + Assert.Empty(result); + } + } + + [Fact] + public async Task Bytes_huge_value_round_trips_via_i32_length() + { + // > 65535 bytes → VL length uses the 5-byte i32 prefix. + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const int key = 4003; + var value = new byte[100000]; + new Random(42).NextBytes(value); + + await region.PutAsync(key, value, ct); + Assert.Equal(value, await region.GetAsync(key, ct)); + } + } + + // ──────────────────────────────────────────────────────────── + // Key-side round-trips (smoke check: non-int key types work on the wire) + // ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Long_key_round_trips_with_int_value() + { + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + // Outside the int range to prove we're not silently truncating. + const long key = (long)int.MaxValue + 1000; + + await region.PutAsync(key, 99, ct); + Assert.Equal(99, await region.GetAsync(key, ct)); + Assert.True(await region.ContainsKeyAsync(key, ct)); + } + } + + [Fact] + public async Task String_key_round_trips_with_int_value() + { + // Smoke test: most common real-world Key shape (string ID). + var (services, region, ct, cts) = await OpenAsync(); + await using (services) + using (cts) + { + const string key = "order:42-中文"; + + await region.PutAsync(key, 7, ct); + Assert.Equal(7, await region.GetAsync(key, ct)); + Assert.True(await region.ContainsKeyAsync(key, ct)); + } + } +} diff --git a/tests/Geode.Client.IntegrationTests/ServerFailoverIntegrationTests.cs b/tests/Geode.Client.IntegrationTests/ServerFailoverIntegrationTests.cs new file mode 100644 index 0000000..8894408 --- /dev/null +++ b/tests/Geode.Client.IntegrationTests/ServerFailoverIntegrationTests.cs @@ -0,0 +1,109 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.IntegrationTests; + +/// +/// End-to-end coverage for 's +/// SendSyncRequestCoreAsync retry frame (Steps A-G, +/// ThinClientPoolDM.cs): a server is taken down mid-session +/// and subsequent ops must succeed via failover to one of the +/// remaining servers. Without this test the retry / excludeServers +/// path is only exercised by unit fakes; the success-path locator +/// round-trip in never +/// observes a real transport failure. +/// +[Collection(nameof(GeodeCollection))] +public class ServerFailoverIntegrationTests(GeodeFixture fx) +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromMinutes(3); + private static readonly TimeSpan FreshConnectionSettleDelay = TimeSpan.FromSeconds(3); + private const string RegionName = "test"; + + [Fact] + public async Task Ops_succeed_via_failover_after_one_server_is_stopped() + { + using var cts = new CancellationTokenSource(TestTimeout); + + await using var services = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)) + .AddGeodeFactory() + .BuildServiceProvider(); + + var cache = await services.GetRequiredService().CreateAsync("c", cts.Token); + + // Pool MUST be locator-mode so the retry frame's + // SelectEndpointAsync goes through the locator and can pick a + // different server when the prior one is excluded. Static-server + // lists would defeat the test (no failover path exercised). + // + // Disable the periodic locator-list refresh: the per-request + // excludeServers carries srv1 quarantine through the retry frame + // already, so refresh adds no value here and just opens a race + // window if the peer list returned by the locator hasn't fully + // propagated the srv1 stop event yet. + await cache.PoolManager.CreateFactory() + .AddLocator(fx.LocatorHost, fx.LocatorPort) + .AddLocator(fx.LocatorHost, fx.LocatorPort2) + .SetUpdateLocatorListInterval(TimeSpan.Zero) + .SetMinConnections(1) + .BuildAsync("default", cts.Token); + + await Task.Delay(FreshConnectionSettleDelay, cts.Token); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync(RegionName, cts.Token); + + // Sanity round trip on the full 3-server cluster. + const int sentinel = 0x6000_0001; + await region.PutAsync(sentinel, -1, cts.Token); + Assert.Equal(-1, await region.GetAsync(sentinel, cts.Token)); + + try + { + // Step 1 — knock srv1 over from outside. The client has no + // way of knowing in advance which server the locator pinned + // its cached connection to; killing srv1 unconditionally + // ensures that AT LEAST one in-flight op below will either + // (a) inherit the broken cached conn and fall into the + // SendSyncRequestCoreAsync catch, or (b) have the locator + // hand it back srv1 (membership lag) and fall into the same + // catch on connect-refused. Either path proves the retry + // frame fires. + await fx.GfshAsync("stop server --name=srv1", cts.Token); + + // Step 2 — drive enough traffic that the locator round-robin + // has multiple chances to return srv1, and any cached conn + // to srv1 gets exercised. 30 keys × 2 ops = 60 hits; any + // unhandled socket / connection-refused that escapes the + // retry frame surfaces as a test failure here. + const int baseKey = 0x6000_1000; + for (var i = 0; i < 30; i++) + { + await region.PutAsync(baseKey + i, i * 2, cts.Token); + } + for (var i = 0; i < 30; i++) + { + Assert.Equal(i * 2, await region.GetAsync(baseKey + i, cts.Token)); + } + } + finally + { + // Restart srv1 so downstream tests in the same collection + // fixture see the full 3-server topology again. + // --dir is left at gfsh's default; membership rejoin only + // needs --locators, log file location is incidental. + await fx.GfshAsync( + $"start server --name=srv1 --server-port={fx.ServerPort} " + + "--hostname-for-clients=localhost " + + $"--locators=localhost[{fx.LocatorPort}],localhost[{fx.LocatorPort2}]", + cts.Token); + } + + await cache.CloseAsync(cts.Token); + } +} diff --git a/tests/Geode.Client.Tests/Geode.Client.Tests.csproj b/tests/Geode.Client.Tests/Geode.Client.Tests.csproj index 5af277b..d510c9f 100644 --- a/tests/Geode.Client.Tests/Geode.Client.Tests.csproj +++ b/tests/Geode.Client.Tests/Geode.Client.Tests.csproj @@ -9,10 +9,16 @@ + - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Geode.Client.Tests/Internal/FakeThinClientBaseDM.cs b/tests/Geode.Client.Tests/Internal/FakeThinClientBaseDM.cs new file mode 100644 index 0000000..d5272c8 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/FakeThinClientBaseDM.cs @@ -0,0 +1,55 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; + +namespace Geode.Client.Tests.Internal; + +/// +/// Test fake for : captures the outbound +/// in and serves a +/// caller-configured . The other three +/// Send* abstract methods all throw +/// so a test that hits the wrong overload flags immediately. +/// +internal sealed class FakeThinClientBaseDM(IServiceProvider sp, GeodeCache cache) + : ThinClientBaseDM(sp, cache) +{ + public TcrMessage? LastRequest { get; private set; } + public TcrMessage? CannedReply { get; set; } + + public override Task SendSyncRequestAsync( + TcrMessage request, bool attemptFailover = true, + bool isBackgroundThread = false, CancellationToken ct = default) + { + LastRequest = request; + return Task.FromResult(CannedReply + ?? throw new InvalidOperationException("CannedReply not set.")); + } + + /// Chunks staged here are fed to HandleChunk in order before returning the canned reply. + public List> StagedChunks { get; } = []; + + public override Task SendSyncRequestAsync( + TcrMessage request, TcrChunkedResult chunkedResult, + bool attemptFailover = true, bool isBackgroundThread = false, + CancellationToken ct = default) + { + LastRequest = request; + chunkedResult.Reset(); + for (var i = 0; i < StagedChunks.Count; i++) + { + var isLast = i == StagedChunks.Count - 1; + chunkedResult.HandleChunk(StagedChunks[i], isLast); + } + return Task.FromResult(CannedReply + ?? throw new InvalidOperationException("CannedReply not set.")); + } + + public override Task SendRequestToEndpointAsync( + TcrMessage request, TcrEndpoint endpoint, CancellationToken ct = default) => + throw new NotSupportedException(); + + public override Task SendRequestToEndpointAsync( + TcrMessage request, TcrChunkedResult chunkedResult, + TcrEndpoint endpoint, CancellationToken ct = default) => + throw new NotSupportedException(); +} diff --git a/tests/Geode.Client.Tests/Internal/PoolAttributesTests.cs b/tests/Geode.Client.Tests/Internal/PoolAttributesTests.cs new file mode 100644 index 0000000..e15802e --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/PoolAttributesTests.cs @@ -0,0 +1,167 @@ +using Geode.Client.Internal; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +public class PoolAttributesTests +{ + // ── Defaults (lock cppcache PoolFactory::DEFAULT_* parity) ──── + + [Fact] + public void Defaults_MatchCppcacheConstants() + { + var a = new PoolAttributes(); + + Assert.Equal(TimeSpan.FromSeconds(10), a.FreeConnectionTimeout); + Assert.Equal(TimeSpan.FromMinutes(5), a.LoadConditioningInterval); + Assert.Equal(32768, a.SocketBufferSize); + Assert.Equal(TimeSpan.FromSeconds(10), a.ReadTimeout); + Assert.Equal(1, a.MinConnections); + Assert.Equal(-1, a.MaxConnections); + Assert.Equal(TimeSpan.FromSeconds(5), a.IdleTimeout); + Assert.Equal(-1, a.RetryAttempts); + Assert.Null(a.PingInterval); // per-pool override; null = inherit from SystemProperties + Assert.Equal(TimeSpan.FromSeconds(5), a.UpdateLocatorListInterval); + Assert.Equal(TimeSpan.Zero, a.StatisticInterval); + Assert.False(a.SubscriptionEnabled); + Assert.Equal(0, a.SubscriptionRedundancy); + Assert.Equal(TimeSpan.FromSeconds(900), a.SubscriptionMessageTrackingTimeout); + Assert.Equal(TimeSpan.FromSeconds(100), a.SubscriptionAckInterval); + Assert.False(a.ThreadLocalConnection); + Assert.False(a.MultiuserSecureMode); + Assert.True(a.PrSingleHopEnabled); + Assert.Equal(string.Empty, a.ServerGroup); + Assert.Equal(string.Empty, a.SniProxyHost); + Assert.Equal(0, a.SniProxyPort); + Assert.Empty(a.Locators); + Assert.Empty(a.Servers); + } + + // ── AddLocator / AddServer mutual exclusion ─────────────────── + + [Fact] + public void AddLocator_AfterAddServer_ThrowsArgumentException() + { + var a = new PoolAttributes(); + a.AddServer("h", 40404); + + Assert.Throws(() => a.AddLocator("h", 10334)); + } + + [Fact] + public void AddServer_AfterAddLocator_ThrowsArgumentException() + { + var a = new PoolAttributes(); + a.AddLocator("h", 10334); + + Assert.Throws(() => a.AddServer("h", 40404)); + } + + // ── Clone semantics ─────────────────────────────────────────── + + [Fact] + public void Clone_CopiesAllScalarFields() + { + var a = new PoolAttributes + { + FreeConnectionTimeout = TimeSpan.FromSeconds(7), + MinConnections = 3, + MaxConnections = 50, + ServerGroup = "g1", + SubscriptionEnabled = true, + PrSingleHopEnabled = false, + SniProxyHost = "sni", + SniProxyPort = 8443, + }; + + var c = a.Clone(); + + Assert.Equal(TimeSpan.FromSeconds(7), c.FreeConnectionTimeout); + Assert.Equal(3, c.MinConnections); + Assert.Equal(50, c.MaxConnections); + Assert.Equal("g1", c.ServerGroup); + Assert.True(c.SubscriptionEnabled); + Assert.False(c.PrSingleHopEnabled); + Assert.Equal("sni", c.SniProxyHost); + Assert.Equal(8443, c.SniProxyPort); + } + + [Fact] + public void Clone_ScalarMutationOnCloneDoesNotAffectOriginal() + { + var a = new PoolAttributes { MinConnections = 1 }; + var c = a.Clone(); + + c.MinConnections = 99; + + Assert.Equal(1, a.MinConnections); + Assert.Equal(99, c.MinConnections); + } + + [Fact] + public void Clone_LocatorsListIsIndependent() + { + var a = new PoolAttributes(); + a.AddLocator("loc1", 10334); + var c = a.Clone(); + + c.AddLocator("loc2", 10335); + + Assert.Single(a.Locators); + Assert.Equal(2, c.Locators.Count); + Assert.Equal("loc1", a.Locators[0].Host); + } + + [Fact] + public void Clone_ServersListIsIndependent() + { + var a = new PoolAttributes(); + a.AddServer("srv1", 40404); + var c = a.Clone(); + + c.AddServer("srv2", 40405); + + Assert.Single(a.Servers); + Assert.Equal(2, c.Servers.Count); + Assert.Equal("srv1", a.Servers[0].Host); + } + + // ── Validate ────────────────────────────────────────────────── + + [Fact] + public void Validate_NoEndpoints_YieldsError() + { + var errors = new PoolAttributes().Validate("X").ToList(); + + Assert.Contains(errors, e => e.Contains("at least one locator or server")); + } + + [Fact] + public void Validate_WithValidServer_NoErrors() + { + var a = new PoolAttributes(); + a.AddServer("h", 40404); + + Assert.Empty(a.Validate("X")); + } + + [Fact] + public void Validate_MaxUnboundedSentinel_IsNotErrored() + { + // -1 = unbounded; the MaxConnections < MinConnections rule must skip it. + var a = new PoolAttributes { MinConnections = 10, MaxConnections = -1 }; + a.AddServer("h", 40404); + + Assert.Empty(a.Validate("X")); + } + + [Fact] + public void Validate_RetryAttemptsNegativeOneSentinel_IsNotErrored() + { + // -1 = pool decides (cppcache DEFAULT_RETRY_ATTEMPTS). + var a = new PoolAttributes { RetryAttempts = -1 }; + a.AddServer("h", 40404); + + Assert.Empty(a.Validate("X")); + } +} diff --git a/tests/Geode.Client.Tests/Internal/RegionAttributesFactoryTests.cs b/tests/Geode.Client.Tests/Internal/RegionAttributesFactoryTests.cs new file mode 100644 index 0000000..e4cd22d --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/RegionAttributesFactoryTests.cs @@ -0,0 +1,100 @@ +using Geode.Client.Internal; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +public class RegionAttributesFactoryTests +{ + // ── Ctors ───────────────────────────────────────────────────── + + [Fact] + public void DefaultCtor_CreateReturnsDefaults() + { + var raf = new RegionAttributesFactory(); + + var a = raf.Create(); + + Assert.Equal(10000, a.InitialCapacity); + Assert.True(a.CachingEnabled); + Assert.Equal(string.Empty, a.PoolName); + } + + [Fact] + public void SeedCtor_NullSeed_ThrowsArgumentNullException() + { + Assert.Throws(() => new RegionAttributesFactory(null!)); + } + + [Fact] + public void SeedCtor_CopiesFromSeed_IndependentOfSeedMutation() + { + var seed = new RegionAttributes { PoolName = "seedPool", InitialCapacity = 42 }; + var raf = new RegionAttributesFactory(seed); + + // Mutate seed after construction — the factory must hold its own copy. + seed.PoolName = "leaked"; + seed.InitialCapacity = 0; + + var a = raf.Create(); + Assert.Equal("seedPool", a.PoolName); + Assert.Equal(42, a.InitialCapacity); + } + + // ── Fluent setters ──────────────────────────────────────────── + + [Fact] + public void Setters_ReturnSameFactoryInstance() + { + var raf = new RegionAttributesFactory(); + + Assert.Same(raf, raf.SetPoolName("p")); + Assert.Same(raf, raf.SetInitialCapacity(64)); + Assert.Same(raf, raf.SetLoadFactor(0.5f)); + Assert.Same(raf, raf.SetConcurrencyLevel(8)); + Assert.Same(raf, raf.SetLruEntriesLimit(100)); + Assert.Same(raf, raf.SetCachingEnabled(false)); + Assert.Same(raf, raf.SetCloningEnabled(true)); + Assert.Same(raf, raf.SetConcurrencyChecksEnabled(false)); + } + + [Fact] + public void Setters_PropagateToCreatedAttributes() + { + var a = new RegionAttributesFactory() + .SetPoolName("p") + .SetInitialCapacity(64) + .SetLoadFactor(0.5f) + .SetConcurrencyLevel(8) + .SetLruEntriesLimit(100) + .SetCachingEnabled(false) + .SetCloningEnabled(true) + .SetConcurrencyChecksEnabled(false) + .Create(); + + Assert.Equal("p", a.PoolName); + Assert.Equal(64, a.InitialCapacity); + Assert.Equal(0.5f, a.LoadFactor); + Assert.Equal(8, a.ConcurrencyLevel); + Assert.Equal(100, a.LruEntriesLimit); + Assert.False(a.CachingEnabled); + Assert.True(a.CloningEnabled); + Assert.False(a.ConcurrencyChecksEnabled); + } + + // ── Create() snapshot semantics ─────────────────────────────── + + [Fact] + public void Create_ReturnsSnapshot_NotLiveAttributes() + { + var raf = new RegionAttributesFactory().SetPoolName("p"); + var snapshot = raf.Create(); + + // Mutating the factory after Create() must not affect the snapshot. + raf.SetPoolName("changed"); + var second = raf.Create(); + + Assert.Equal("p", snapshot.PoolName); + Assert.Equal("changed", second.PoolName); + Assert.NotSame(snapshot, second); + } +} diff --git a/tests/Geode.Client.Tests/Internal/RegionAttributesTests.cs b/tests/Geode.Client.Tests/Internal/RegionAttributesTests.cs new file mode 100644 index 0000000..e005a24 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/RegionAttributesTests.cs @@ -0,0 +1,76 @@ +using Geode.Client.Internal; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +public class RegionAttributesTests +{ + // ── Defaults (lock cppcache RegionAttributes.cpp:43-58 parity) ── + + [Fact] + public void Defaults_MatchCppcacheConstants() + { + var a = new RegionAttributes(); + + Assert.Equal(10000, a.InitialCapacity); + Assert.Equal(0.75f, a.LoadFactor); + Assert.Equal(16, a.ConcurrencyLevel); + Assert.Equal(0, a.LruEntriesLimit); + Assert.True(a.CachingEnabled); + Assert.False(a.CloningEnabled); + Assert.True(a.ConcurrencyChecksEnabled); + Assert.Equal(string.Empty, a.PoolName); + } + + // ── Clone semantics ─────────────────────────────────────────── + + [Fact] + public void Clone_ReturnsNewInstance() + { + var a = new RegionAttributes(); + var b = a.Clone(); + + Assert.NotSame(a, b); + } + + [Fact] + public void Clone_CopiesAllFields() + { + var a = new RegionAttributes + { + InitialCapacity = 42, + LoadFactor = 0.5f, + ConcurrencyLevel = 8, + LruEntriesLimit = 100, + CachingEnabled = false, + CloningEnabled = true, + ConcurrencyChecksEnabled = false, + PoolName = "p", + }; + + var b = a.Clone(); + + Assert.Equal(42, b.InitialCapacity); + Assert.Equal(0.5f, b.LoadFactor); + Assert.Equal(8, b.ConcurrencyLevel); + Assert.Equal(100, b.LruEntriesLimit); + Assert.False(b.CachingEnabled); + Assert.True(b.CloningEnabled); + Assert.False(b.ConcurrencyChecksEnabled); + Assert.Equal("p", b.PoolName); + } + + [Fact] + public void Clone_IsIndependent() + { + var a = new RegionAttributes { PoolName = "p", InitialCapacity = 42 }; + var b = a.Clone(); + + b.PoolName = "q"; + b.InitialCapacity = 99; + + // Mutations on the clone don't leak back. + Assert.Equal("p", a.PoolName); + Assert.Equal(42, a.InitialCapacity); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionClearTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionClearTests.cs new file mode 100644 index 0000000..f878e92 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionClearTests.cs @@ -0,0 +1,138 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .ClearAsync — +/// locks the cppcache TcrMessageClearRegion wire layout +/// (TcrMessage.cpp:1644-1682): 2 parts (Region + EventId) and +/// the reply dispatch matrix. +/// +public class ThinClientRegionClearTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders") => + new(sp, NullLogger.Instance, name, new RegionAttributes(), dm); + + private static TcrMessage MakeReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasTwoPartsWithMessageTypeClearRegion() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.ClearAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(dm.LastRequest); + Assert.Equal(MessageType.ClearRegion, dm.LastRequest.MessageType); + Assert.Equal(2, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_Part0IsRegionName() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + + await region.ClearAsync(TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part1IsEventId_LongCodeFramedI64Pair() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.ClearAsync(TestContext.Current.CancellationToken); + + var eventIdPart = dm.LastRequest!.Parts[1]; + Assert.Equal(0, eventIdPart.IsObject); + Assert.Equal(18, eventIdPart.Payload.Length); + Assert.Equal(0x03, eventIdPart.Payload.Span[0]); + Assert.Equal(0x03, eventIdPart.Payload.Span[9]); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Reply_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.ClearAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.ClearAsync(TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on Clear", ex.Message); + } + + [Fact] + public async Task ClearRegionDataError_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.ClearRegionDataError) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.ClearAsync(TestContext.Current.CancellationToken)); + Assert.Contains("ClearRegionDataError", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.ClearAsync(TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionContainsKeyTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionContainsKeyTests.cs new file mode 100644 index 0000000..9893ae5 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionContainsKeyTests.cs @@ -0,0 +1,190 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .ContainsKeyAsync — +/// locks the wire layout (cppcache TcrMessage.cpp:1808-1842) and +/// the reply dispatch matrix. A fake +/// captures the request and serves canned replies so we test the region +/// in isolation, no pool / no socket. +/// +public class ThinClientRegionContainsKeyTests +{ + // ── DI host ──────────────────────────────────────────────────── + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders") => + new(sp, NullLogger.Instance, name, new RegionAttributes(), dm); + + /// Build a message carrying a single CacheableBoolean part. + private static TcrMessage MakeBoolReply(IServiceProvider sp, GeodeCache cache, bool value) + { + using var output = ActivatorUtilities.CreateInstance(sp); + cache.SerializationRegistry.WriteObjectAsync(output, value).AsTask().GetAwaiter().GetResult(); + var part = new TcrPart(IsObject: 1, output.WrittenSpan.ToArray()); + return new TcrMessage(sp, MessageType.Response, TransactionId: -1, EarlyAck: 0, [part]); + } + + /// Build a non-bool Response payload (encoded ) for the negative test. + private static TcrMessage MakeInt32Reply(IServiceProvider sp, GeodeCache cache, int value) + { + using var output = ActivatorUtilities.CreateInstance(sp); + cache.SerializationRegistry.WriteObjectAsync(output, value).AsTask().GetAwaiter().GetResult(); + var part = new TcrPart(IsObject: 1, output.WrittenSpan.ToArray()); + return new TcrMessage(sp, MessageType.Response, TransactionId: -1, EarlyAck: 0, [part]); + } + + private static TcrMessage MakeEmptyReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Wire layout (regression lock for the op-flag bug) ────────── + + [Fact] + public async Task WireLayout_HasThreePartsWithMessageTypeContainsKey() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeBoolReply(sp, cache, true) }; + var region = MakeRegion(sp, cache, dm); + + await region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken); + + Assert.NotNull(dm.LastRequest); + Assert.Equal(MessageType.ContainsKey, dm.LastRequest.MessageType); + Assert.Equal(3, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_OpFlagPartIsZero_NotOne() + { + // Regression lock for the wire bug previously here: cppcache + // TcrMessage.cpp:1837 — 0 = containsKey, 1 = containsValueForKey. + // ContainsKeyAsync must send 0; ContainsValueForKeyAsync (when + // it ships) will send 1. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeBoolReply(sp, cache, true) }; + var region = MakeRegion(sp, cache, dm); + + await region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken); + + // Part 3 (index 2) is a raw int32 BE op-flag, IsObject=0. + var opFlagPart = dm.LastRequest!.Parts[2]; + Assert.Equal(0, opFlagPart.IsObject); + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, opFlagPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_RegionNamePartCarriesFullPath() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeBoolReply(sp, cache, true) }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + // ThinClientRegion is constructed with parent=null → FullPath = "/orders". + + await region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + // Modified UTF-8 for pure ASCII == ASCII bytes verbatim. + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Response_TrueBool_ReturnsTrue() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeBoolReply(sp, cache, true) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task Response_FalseBool_ReturnsFalse() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeBoolReply(sp, cache, false) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken); + + Assert.False(result); + } + + [Fact] + public async Task Response_NonBoolPayload_ThrowsGeodeException() + { + // Server reply with the wrong CLR type in Parts[0] (here: int). + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeInt32Reply(sp, cache, 42) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("expected bool reply", ex.Message); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) + { + CannedReply = MakeEmptyReply(sp, MessageType.Exception), + }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on ContainsKey", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + // Anything outside Response / Exception falls into the default + // arm — defensive check against a broken codec or server bug. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) + { + CannedReply = MakeEmptyReply(sp, MessageType.Ping), + }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.ContainsKeyAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } + +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionExistsSelectValueTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionExistsSelectValueTests.cs new file mode 100644 index 0000000..82d6756 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionExistsSelectValueTests.cs @@ -0,0 +1,98 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for the OQL-backed region convenience methods +/// / +/// . The wire path itself +/// (RemoteQueryService → RemoteQuery → MessageType.Query/QueryWithParameters +/// → ChunkedQueryResponse) needs a real ; +/// success-path coverage is in the integration test. This file locks the +/// input-validation + non-pool-DM guard that fires before any wire +/// machinery. +/// +public class ThinClientRegionExistsSelectValueTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm) => + new(sp, NullLogger.Instance, "orders", new RegionAttributes(), dm); + + // ── Predicate validation: empty / whitespace ────────────────── + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task ExistsValueAsync_EmptyOrWhitespacePredicate_ThrowsArgumentException(string predicate) + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache); + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.ExistsValueAsync(predicate, TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task SelectValueAsync_EmptyOrWhitespacePredicate_ThrowsArgumentException(string predicate) + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache); + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.SelectValueAsync(predicate, TestContext.Current.CancellationToken)); + } + + // ── Non-pool DM guard ───────────────────────────────────────── + + [Fact] + public async Task ExistsValueAsync_NonPoolDM_ThrowsNotImplementedException() + { + // FakeThinClientBaseDM is the abstract base, not ThinClientPoolDM, + // so QueryAsync's `dm is not ThinClientPoolDM` guard fires. + // Memory pool-only-no-non-pool.md: non-pool DM routing deferred. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache); + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.ExistsValueAsync("this='x'", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SelectValueAsync_NonPoolDM_ThrowsNotImplementedException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache); + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.SelectValueAsync("this='x'", TestContext.Current.CancellationToken)); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionGetAllTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionGetAllTests.cs new file mode 100644 index 0000000..d439e88 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionGetAllTests.cs @@ -0,0 +1,167 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .GetAllAsync — +/// locks the cppcache TcrMessageGetAll wire layout +/// (TcrMessage.cpp:2470-2502): 3 parts (Region + +/// keys-as-CacheableObjectArray + int(0) callback placeholder) and the +/// reply dispatch matrix. +/// Success-path value decoding lives in the integration test (real +/// chunked reply); here we lock layout + error paths. +/// +public class ThinClientRegionGetAllTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm) => + new(sp, NullLogger.Instance, "orders", new RegionAttributes(), dm); + + private static TcrMessage MakeReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Input validation ─────────────────────────────────────────── + + [Fact] + public async Task NullKeys_ThrowsArgumentNullException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.GetAllAsync(null!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EmptyKeys_ThrowsArgumentException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.GetAllAsync(Array.Empty(), TestContext.Current.CancellationToken)); + } + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasThreePartsWithMessageTypeGetAll70() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await region.GetAllAsync(new object[] { "k1", "k2" }, TestContext.Current.CancellationToken); + + Assert.Equal(MessageType.GetAll70, dm.LastRequest!.MessageType); + Assert.Equal(3, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_Part1IsCacheableObjectArrayDSCode() + { + // cppcache writeObjectPart(nullptr, false, false, m_keyList) — + // serializes keys as CacheableObjectArray (DSCode 52). + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await region.GetAllAsync(new object[] { "k1", "k2" }, TestContext.Current.CancellationToken); + + var keysPart = dm.LastRequest!.Parts[1]; + Assert.Equal(1, keysPart.IsObject); + Assert.Equal(DSCode.CacheableObjectArray, keysPart.Payload.Span[0]); + } + + [Fact] + public async Task WireLayout_Part2IsCallbackPlaceholderIntZero() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await region.GetAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken); + + Assert.Equal(0, dm.LastRequest!.Parts[2].IsObject); + Assert.Equal(new byte[] { 0, 0, 0, 0 }, dm.LastRequest.Parts[2].Payload.ToArray()); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Response_NoChunks_ReturnsEmptyValuesDict() + { + // No chunks staged → chunkedResult.Values stays empty. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.GetAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.GetAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on GetAll", ex.Message); + } + + [Fact] + public async Task GetAllDataError_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.GetAllDataError) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.GetAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken)); + Assert.Contains("GetAllDataError", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.GetAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionGetTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionGetTests.cs new file mode 100644 index 0000000..54ead1d --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionGetTests.cs @@ -0,0 +1,176 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .GetAsync — locks +/// the wire layout (cppcache TcrMessageRequest) and the reply +/// dispatch matrix (Response → decode, Exception → throw, missing key +/// → null via empty IsObject=0 part). +/// +public class ThinClientRegionGetTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders") => + new(sp, NullLogger.Instance, name, new RegionAttributes(), dm); + + private static TcrMessage MakeValueReply(IServiceProvider sp, GeodeCache cache, object value) + { + using var output = ActivatorUtilities.CreateInstance(sp); + cache.SerializationRegistry.WriteObjectAsync(output, value).AsTask().GetAwaiter().GetResult(); + var part = new TcrPart(IsObject: 1, output.WrittenSpan.ToArray()); + return new TcrMessage(sp, MessageType.Response, TransactionId: -1, EarlyAck: 0, [part]); + } + + /// + /// Cache-miss reply: cppcache readObjectPart empty branch — + /// IsObject=0, zero-length payload → key absent. + /// + private static TcrMessage MakeMissReply(IServiceProvider sp) => + new(sp, MessageType.Response, TransactionId: -1, EarlyAck: 0, + [new TcrPart(IsObject: 0, ReadOnlyMemory.Empty)]); + + private static TcrMessage MakeEmptyReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasTwoPartsWithMessageTypeRequest() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeValueReply(sp, cache, "v") }; + var region = MakeRegion(sp, cache, dm); + + await region.GetAsync("k1", TestContext.Current.CancellationToken); + + Assert.NotNull(dm.LastRequest); + Assert.Equal(MessageType.Request, dm.LastRequest.MessageType); + Assert.Equal(2, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_RegionNamePartCarriesFullPath() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeValueReply(sp, cache, "v") }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + + await region.GetAsync("k1", TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Response_DSCodeTaggedValue_DecodesRoundTrip() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeValueReply(sp, cache, "hello") }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.GetAsync("k1", TestContext.Current.CancellationToken); + + Assert.Equal("hello", result); + } + + [Fact] + public async Task Response_DSCodeTaggedInt32_DecodesAsInt() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeValueReply(sp, cache, 42) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.GetAsync("k1", TestContext.Current.CancellationToken); + + Assert.Equal(42, result); + } + + [Fact] + public async Task Response_EmptyIsObjectZero_ReturnsNull() + { + // Cache miss: cppcache readObjectPart payloadEmpty + IsObject=0 → null. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeMissReply(sp) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.GetAsync("k1", TestContext.Current.CancellationToken); + + Assert.Null(result); + } + + [Fact] + public async Task Response_ZeroParts_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) + { + CannedReply = MakeEmptyReply(sp, MessageType.Response), + }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.GetAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Response with zero parts", ex.Message); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) + { + CannedReply = MakeEmptyReply(sp, MessageType.Exception), + }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.GetAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on Get", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) + { + CannedReply = MakeEmptyReply(sp, MessageType.Ping), + }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.GetAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionInvalidateTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionInvalidateTests.cs new file mode 100644 index 0000000..19e368b --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionInvalidateTests.cs @@ -0,0 +1,151 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .InvalidateAsync — +/// locks the cppcache TcrMessageInvalidate wire layout +/// (TcrMessage.cpp:1896-1932): 3 parts (Region + Key + EventId) +/// and the reply dispatch matrix (Reply / Exception / InvalidateError / +/// unknown). +/// +public class ThinClientRegionInvalidateTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders") => + new(sp, NullLogger.Instance, name, new RegionAttributes(), dm); + + private static TcrMessage MakeReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasThreePartsWithMessageTypeInvalidate() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.InvalidateAsync("k1", TestContext.Current.CancellationToken); + + Assert.NotNull(dm.LastRequest); + Assert.Equal(MessageType.Invalidate, dm.LastRequest.MessageType); + Assert.Equal(3, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_Part0IsRegionName() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + + await region.InvalidateAsync("k1", TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part2IsEventId() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.InvalidateAsync("k1", TestContext.Current.CancellationToken); + + var eventIdPart = dm.LastRequest!.Parts[2]; + Assert.Equal(0, eventIdPart.IsObject); + Assert.Equal(18, eventIdPart.Payload.Length); + } + + // ── Input validation ─────────────────────────────────────────── + + [Fact] + public async Task NullKey_ThrowsArgumentNullException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.InvalidateAsync(null!, TestContext.Current.CancellationToken)); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Reply_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.InvalidateAsync("k1", TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.InvalidateAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on Invalidate", ex.Message); + } + + [Fact] + public async Task InvalidateError_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.InvalidateError) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.InvalidateAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("InvalidateError", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.InvalidateAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionPutAllTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionPutAllTests.cs new file mode 100644 index 0000000..5c71fe8 --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionPutAllTests.cs @@ -0,0 +1,219 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .PutAllAsync — +/// locks the cppcache TcrMessagePutAll wire layout +/// (TcrMessage.cpp:2354-2422): 5 + 2N parts in the order +/// Region + EventId + reserved(0) + flags + count + N×(Key, Value), +/// plus reply dispatch matrix. +/// +public class ThinClientRegionPutAllTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders", bool cachingEnabled = false, + bool concurrencyChecksEnabled = false) => + new(sp, NullLogger.Instance, name, + new RegionAttributes + { + CachingEnabled = cachingEnabled, + ConcurrencyChecksEnabled = concurrencyChecksEnabled, + }, + dm); + + private static TcrMessage MakeReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Input validation ─────────────────────────────────────────── + + [Fact] + public async Task NullMap_ThrowsArgumentNullException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.PutAllAsync(null!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EmptyMap_ThrowsArgumentException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.PutAllAsync( + new Dictionary(), TestContext.Current.CancellationToken)); + } + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasFivePlus2NPartsWithMessageTypePutAll() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + var map = new Dictionary { ["k1"] = "v1", ["k2"] = "v2", ["k3"] = "v3" }; + + await region.PutAllAsync(map, TestContext.Current.CancellationToken); + + Assert.Equal(MessageType.PutAll, dm.LastRequest!.MessageType); + Assert.Equal(5 + 2 * map.Count, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_Part0IsRegionName() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + + await region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part2IsReservedIntZero() + { + // cppcache TcrMessage.cpp:2390 — writeIntPart(0) reserved slot. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken); + + Assert.Equal(0, dm.LastRequest!.Parts[2].IsObject); + Assert.Equal(new byte[] { 0, 0, 0, 0 }, dm.LastRequest.Parts[2].Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part3IsFlags_OneForProxyRegion() + { + // cppcache flags: 1 = EMPTY (caching disabled) → proxy regions. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm, cachingEnabled: false); + + await region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken); + + Assert.Equal(new byte[] { 0, 0, 0, 1 }, dm.LastRequest!.Parts[3].Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part4IsEntryCount() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAllAsync( + new Dictionary { ["k1"] = "v1", ["k2"] = "v2" }, + TestContext.Current.CancellationToken); + + Assert.Equal(new byte[] { 0, 0, 0, 2 }, dm.LastRequest!.Parts[4].Payload.ToArray()); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Reply_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Response_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on PutAll", ex.Message); + } + + [Fact] + public async Task PutDataError_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.PutDataError) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken)); + Assert.Contains("PutDataError", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.PutAllAsync( + new Dictionary { ["k1"] = "v1" }, TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionPutTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionPutTests.cs new file mode 100644 index 0000000..c9192be --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionPutTests.cs @@ -0,0 +1,186 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .PutAsync — locks +/// the cppcache TcrMessagePut wire layout +/// (cppcache/src/TcrMessage.cpp:1989-2033): 7 parts in the order +/// region + null-op + flags + key + isDelta + value + eventId, and the +/// reply dispatch matrix. +/// +public class ThinClientRegionPutTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders") => + new(sp, NullLogger.Instance, name, new RegionAttributes(), dm); + + private static TcrMessage MakeReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasSevenPartsWithMessageTypePut() + { + // cppcache TcrMessage.cpp:1999, 2021-2032 — m_msgType = PUT; + // 7 parts in order: Region + NullOp + Flags + Key + IsDelta + + // Value + EventId (no callback). + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + + Assert.NotNull(dm.LastRequest); + Assert.Equal(MessageType.Put, dm.LastRequest.MessageType); + Assert.Equal(7, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_RegionNamePartCarriesFullPath() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part1IsNullOp_DSCode41() + { + // cppcache writeObjectPart(nullptr) → single-byte DSCode.NullObj, + // IsObject=1. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + + var nullOp = dm.LastRequest!.Parts[1]; + Assert.Equal(1, nullOp.IsObject); + Assert.Equal(new byte[] { DSCode.NullObj }, nullOp.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part2IsFlagsInt32Zero() + { + // cppcache writeIntPart(0) — i32 BE = 0, IsObject=0. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + + var flagsPart = dm.LastRequest!.Parts[2]; + Assert.Equal(0, flagsPart.IsObject); + Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, flagsPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part4IsCacheableBooleanIsDeltaFalse() + { + // cppcache writeObjectPart(CacheableBoolean::create(false)) — + // [DSCode 53, 0x00], IsObject=1. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + + var isDeltaPart = dm.LastRequest!.Parts[4]; + Assert.Equal(1, isDeltaPart.IsObject); + Assert.Equal(new byte[] { DSCode.CacheableBoolean, 0x00 }, isDeltaPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part6IsEventId_LongCodeFramedI64Pair() + { + // cppcache EventId::writeIdsData (EventId.hpp:95-107): + // [0x03][threadId i64 BE][0x03][sequenceId i64 BE], IsObject=0. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + + var eventIdPart = dm.LastRequest!.Parts[6]; + Assert.Equal(0, eventIdPart.IsObject); + Assert.Equal(18, eventIdPart.Payload.Length); + var bytes = eventIdPart.Payload.Span; + Assert.Equal(0x03, bytes[0]); // longCode for threadId + Assert.Equal(0x03, bytes[9]); // longCode for sequenceId + // ThreadId is EventIdGenerator.ThreadId const = 1; first Next() bumps SequenceId to 1. + Assert.Equal(new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }, bytes[1..9].ToArray()); + Assert.Equal(new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }, bytes[10..18].ToArray()); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Reply_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + // No exception → success. + await region.PutAsync("k1", "v1", TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.PutAsync("k1", "v1", TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on Put", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.PutAsync("k1", "v1", TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionRemoveAllTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionRemoveAllTests.cs new file mode 100644 index 0000000..7faa8df --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionRemoveAllTests.cs @@ -0,0 +1,183 @@ +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .RemoveAllAsync — +/// locks the cppcache TcrMessageRemoveAll wire layout +/// (TcrMessage.cpp:2424-2468): 5 + N parts in the order +/// Region + EventId + flags + NullObj(callback) + count + N×Key, plus +/// reply dispatch matrix. +/// +public class ThinClientRegionRemoveAllTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + bool cachingEnabled = false, + bool concurrencyChecksEnabled = false) => + new(sp, NullLogger.Instance, "orders", + new RegionAttributes + { + CachingEnabled = cachingEnabled, + ConcurrencyChecksEnabled = concurrencyChecksEnabled, + }, + dm); + + private static TcrMessage MakeReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Input validation ─────────────────────────────────────────── + + [Fact] + public async Task NullKeys_ThrowsArgumentNullException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.RemoveAllAsync(null!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EmptyKeys_ThrowsArgumentException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.RemoveAllAsync(Array.Empty(), TestContext.Current.CancellationToken)); + } + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasFivePlusNPartsWithMessageTypeRemoveAll() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + var keys = new object[] { "k1", "k2", "k3" }; + + await region.RemoveAllAsync(keys, TestContext.Current.CancellationToken); + + Assert.Equal(MessageType.RemoveAll, dm.LastRequest!.MessageType); + Assert.Equal(5 + keys.Length, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_Part2IsFlags_OneForProxyRegion() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm, cachingEnabled: false); + + await region.RemoveAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken); + + // cppcache TcrMessage.cpp:2450-2459 — flags is part 2 for RemoveAll + // (Region=0, EventId=1, flags=2, NullObj(callback)=3, count=4, keys...). + Assert.Equal(new byte[] { 0, 0, 0, 1 }, dm.LastRequest!.Parts[2].Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part3IsNullObjCallback() + { + // cppcache writeObjectPart(aCallbackArgument) — null arg → + // DSCode.NullObj, IsObject=1. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken); + + var cb = dm.LastRequest!.Parts[3]; + Assert.Equal(1, cb.IsObject); + Assert.Equal(new byte[] { DSCode.NullObj }, cb.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part4IsKeyCount() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAllAsync(new object[] { "k1", "k2" }, TestContext.Current.CancellationToken); + + Assert.Equal(new byte[] { 0, 0, 0, 2 }, dm.LastRequest!.Parts[4].Payload.ToArray()); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Reply_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Reply) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Response_MessageType_CompletesSuccessfully() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Response) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.RemoveAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on RemoveAll", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.RemoveAllAsync(new object[] { "k1" }, TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/Internal/ThinClientRegionRemoveTests.cs b/tests/Geode.Client.Tests/Internal/ThinClientRegionRemoveTests.cs new file mode 100644 index 0000000..9e0ae0b --- /dev/null +++ b/tests/Geode.Client.Tests/Internal/ThinClientRegionRemoveTests.cs @@ -0,0 +1,187 @@ +using System.Buffers.Binary; +using Geode.Client.Internal; +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Internal; + +/// +/// Unit-level tests for .RemoveAsync — +/// locks the cppcache TcrMessageDestroy null-value-branch wire +/// layout (TcrMessage.cpp:1974-1985): 5 parts (Region + Key + +/// NullObj(expectedOldValue) + NullObj(operation) + EventId) and the +/// reply decode (entryNotFound i32 in the last reply part: 0 = removed, +/// 1 = absent). +/// +public class ThinClientRegionRemoveTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static GeodeCache MakeCache(IServiceProvider sp) => + ActivatorUtilities.CreateInstance(sp, "c"); + + private static ThinClientRegion MakeRegion( + IServiceProvider sp, GeodeCache cache, FakeThinClientBaseDM dm, + string name = "orders") => + new(sp, NullLogger.Instance, name, new RegionAttributes(), dm); + + /// Build a Reply carrying an entryNotFound i32 in its last part (cppcache TcrMessage.cpp:1317-1330). + private static TcrMessage MakeDestroyReply(IServiceProvider sp, int entryNotFound) + { + var bytes = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(bytes, entryNotFound); + return new TcrMessage(sp, MessageType.Reply, TransactionId: -1, EarlyAck: 0, + [new TcrPart(IsObject: 0, bytes)]); + } + + private static TcrMessage MakeEmptyReply(IServiceProvider sp, MessageType messageType) => + new(sp, messageType, TransactionId: -1, EarlyAck: 0, []); + + // ── Wire layout ──────────────────────────────────────────────── + + [Fact] + public async Task WireLayout_HasFivePartsWithMessageTypeDestroy() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 0) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAsync("k1", TestContext.Current.CancellationToken); + + Assert.NotNull(dm.LastRequest); + Assert.Equal(MessageType.Destroy, dm.LastRequest.MessageType); + Assert.Equal(5, dm.LastRequest.Parts.Count); + } + + [Fact] + public async Task WireLayout_Part0IsRegionName() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 0) }; + var region = MakeRegion(sp, cache, dm, name: "orders"); + + await region.RemoveAsync("k1", TestContext.Current.CancellationToken); + + var regionPart = dm.LastRequest!.Parts[0]; + Assert.Equal(0, regionPart.IsObject); + Assert.Equal("/orders"u8.ToArray(), regionPart.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Parts2And3AreNullObj() + { + // cppcache TcrMessage.cpp:1979-1980 — expectedOldValue + operation + // both written as writeObjectPart(nullptr) when caller hands null + // value/op (which our public Remove API always does). + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 0) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAsync("k1", TestContext.Current.CancellationToken); + + var expectedOldValue = dm.LastRequest!.Parts[2]; + var operation = dm.LastRequest!.Parts[3]; + Assert.Equal(1, expectedOldValue.IsObject); + Assert.Equal(new byte[] { DSCode.NullObj }, expectedOldValue.Payload.ToArray()); + Assert.Equal(1, operation.IsObject); + Assert.Equal(new byte[] { DSCode.NullObj }, operation.Payload.ToArray()); + } + + [Fact] + public async Task WireLayout_Part4IsEventId() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 0) }; + var region = MakeRegion(sp, cache, dm); + + await region.RemoveAsync("k1", TestContext.Current.CancellationToken); + + var eventIdPart = dm.LastRequest!.Parts[4]; + Assert.Equal(0, eventIdPart.IsObject); + Assert.Equal(18, eventIdPart.Payload.Length); + } + + // ── Input validation ─────────────────────────────────────────── + + [Fact] + public async Task NullKey_ThrowsArgumentNullException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 0) }; + var region = MakeRegion(sp, cache, dm); + + await Assert.ThrowsAsync( + () => region.RemoveAsync(null!, TestContext.Current.CancellationToken)); + } + + // ── Reply dispatch ───────────────────────────────────────────── + + [Fact] + public async Task Reply_EntryNotFoundZero_ReturnsTrue() + { + // entryNotFound=0 → server destroyed the entry → method returns true. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 0) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.RemoveAsync("k1", TestContext.Current.CancellationToken); + + Assert.True(result); + } + + [Fact] + public async Task Reply_EntryNotFoundOne_ReturnsFalse() + { + // entryNotFound=1 → key didn't exist → method returns false. + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeDestroyReply(sp, 1) }; + var region = MakeRegion(sp, cache, dm); + + var result = await region.RemoveAsync("k1", TestContext.Current.CancellationToken); + + Assert.False(result); + } + + [Fact] + public async Task Exception_MessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeEmptyReply(sp, MessageType.Exception) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.RemoveAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Server exception on Remove", ex.Message); + } + + [Fact] + public async Task UnknownMessageType_ThrowsGeodeException() + { + await using var sp = BuildSp(); + var cache = MakeCache(sp); + var dm = new FakeThinClientBaseDM(sp, cache) { CannedReply = MakeEmptyReply(sp, MessageType.Ping) }; + var region = MakeRegion(sp, cache, dm); + + var ex = await Assert.ThrowsAsync( + () => region.RemoveAsync("k1", TestContext.Current.CancellationToken)); + Assert.Contains("Unexpected reply type", ex.Message); + } +} diff --git a/tests/Geode.Client.Tests/PoolFactoryTests.cs b/tests/Geode.Client.Tests/PoolFactoryTests.cs new file mode 100644 index 0000000..1f1bc51 --- /dev/null +++ b/tests/Geode.Client.Tests/PoolFactoryTests.cs @@ -0,0 +1,125 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Geode.Client.Tests; + +public class PoolFactoryTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static async Task BuildFactoryAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + return cache.PoolManager.CreateFactory(); + } + + [Fact] + public async Task Setters_ReturnSameFactoryInstance() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)); + + Assert.Same(f, f.SetMinConnections(1)); + Assert.Same(f, f.SetMaxConnections(10)); + Assert.Same(f, f.SetIdleTimeout(TimeSpan.FromSeconds(5))); + Assert.Same(f, f.AddServer("h", 40404)); + Assert.Same(f, f.Reset()); + } + + [Fact] + public async Task AddLocator_AfterAddServer_ThrowsArgumentException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)); + f.AddServer("h", 40404); + + Assert.Throws(() => f.AddLocator("h", 10334)); + } + + [Fact] + public async Task AddServer_AfterAddLocator_ThrowsArgumentException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)); + f.AddLocator("h", 10334); + + Assert.Throws(() => f.AddServer("h", 40404)); + } + + [Fact] + public async Task BuildAsync_WithoutEndpoints_ThrowsOptionsValidationException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)); + + await Assert.ThrowsAsync(() => f.BuildAsync("p", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task BuildAsync_MaxConnectionsLessThanMin_ThrowsOptionsValidationException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)) + .AddServer("h", 40404) + .SetMinConnections(10) + .SetMaxConnections(5); + + await Assert.ThrowsAsync(() => f.BuildAsync("p", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task BuildAsync_NegativeIdleTimeout_ThrowsOptionsValidationException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)) + .AddServer("h", 40404) + .SetIdleTimeout(TimeSpan.FromSeconds(-1)); + + await Assert.ThrowsAsync(() => f.BuildAsync("p", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task BuildAsync_InvalidPort_ThrowsOptionsValidationException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)).AddServer("h", 0); + + await Assert.ThrowsAsync(() => f.BuildAsync("p", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task BuildAsync_EmptyHost_ThrowsOptionsValidationException() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)).AddServer("", 40404); + + await Assert.ThrowsAsync(() => f.BuildAsync("p", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Reset_ClearsEndpoints() + { + await using var sp = BuildSp(); + var f = (await BuildFactoryAsync(sp, TestContext.Current.CancellationToken)); + f.AddLocator("h", 10334); + + // Sanity check: mutual exclusion is active before Reset. + Assert.Throws(() => f.AddServer("h", 40404)); + + f.Reset(); + + // After Reset, switching endpoint kind succeeds. + f.AddServer("h", 40404); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/BooleanArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/BooleanArrayDataConverterTests.cs new file mode 100644 index 0000000..8c079d0 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/BooleanArrayDataConverterTests.cs @@ -0,0 +1,56 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class BooleanArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.BooleanArray, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_one_byte_per_element_after_inline_length() + { + // {true, false, true} → DSCode + length-3 + 0x01 / 0x00 / 0x01. + Assert.Equal( + new byte[] { DSCode.BooleanArray, 0x03, 0x01, 0x00, 0x01 }, + SerializationTestHelpers.Encode(new[] { true, false, true })); + } + + [Fact] + public void Decode_zero_length_returns_empty_array() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.BooleanArray, 0x00 }); + Assert.Equal(Array.Empty(), result); + } + + [Fact] + public void Decode_treats_any_non_zero_byte_as_true() + { + // cppcache ReadBool: any non-zero byte is true. Server-side + // values arrive normalised to 0x01, but be tolerant on read. + var result = (bool[])SerializationTestHelpers.Decode( + new byte[] { DSCode.BooleanArray, 0x02, 0xFF, 0x00 })!; + Assert.Equal(new[] { true, false }, result); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new[] { true })] + [InlineData(new[] { false })] + [InlineData(new[] { true, false, true, true, false })] + public void RoundTrip_small(bool[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/BooleanDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/BooleanDataConverterTests.cs new file mode 100644 index 0000000..390319f --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/BooleanDataConverterTests.cs @@ -0,0 +1,47 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class BooleanDataConverterTests +{ + [Fact] + public void Encode_true_writes_dscode_and_one_byte() + { + Assert.Equal( + new byte[] { DSCode.CacheableBoolean, 0x01 }, + SerializationTestHelpers.Encode(true)); + } + + [Fact] + public void Encode_false_writes_dscode_and_zero_byte() + { + Assert.Equal( + new byte[] { DSCode.CacheableBoolean, 0x00 }, + SerializationTestHelpers.Encode(false)); + } + + [Fact] + public void Decode_zero_byte_returns_false() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableBoolean, 0x00 }); + Assert.Equal(false, result); + } + + [Fact] + public void Decode_non_zero_byte_returns_true() + { + // Any non-zero byte is "true" per Java DataInput.readBoolean + // semantics; we accept 0xFF the same as 0x01. + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableBoolean, 0xFF }); + Assert.Equal(true, result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RoundTrip(bool value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/ByteDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/ByteDataConverterTests.cs new file mode 100644 index 0000000..b39d889 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/ByteDataConverterTests.cs @@ -0,0 +1,43 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class ByteDataConverterTests +{ + [Fact] + public void Encode_zero_writes_dscode_and_zero() + { + Assert.Equal( + new byte[] { DSCode.CacheableByte, 0x00 }, + SerializationTestHelpers.Encode((byte)0)); + } + + [Fact] + public void Encode_max_writes_dscode_and_FF() + { + Assert.Equal( + new byte[] { DSCode.CacheableByte, 0xFF }, + SerializationTestHelpers.Encode(byte.MaxValue)); + } + + [Fact] + public void Decode_byte_preserves_full_u8_range() + { + // .NET byte=255 ↔ Java byte=-1 share wire 0xFF; round-trip + // through .NET is value-preserving even at the unsigned/signed + // boundary because we never interpret as signed. + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableByte, 0xFF }); + Assert.Equal((byte)255, result); + } + + [Theory] + [InlineData((byte)0)] + [InlineData((byte)1)] + [InlineData((byte)127)] // .NET = Java 127 + [InlineData((byte)128)] // .NET = 128, Java = -128 — bit pattern 0x80 + [InlineData((byte)255)] // .NET = 255, Java = -1 + public void RoundTrip(byte value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/BytesDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/BytesDataConverterTests.cs new file mode 100644 index 0000000..8c43573 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/BytesDataConverterTests.cs @@ -0,0 +1,111 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class BytesDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + // VL-encoded length 0 = single byte 0x00, no body. + Assert.Equal( + new byte[] { DSCode.CacheableBytes, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_short_array_writes_dscode_and_inline_length() + { + // Length 3 fits in the 0..252 inline range → 1-byte length prefix. + Assert.Equal( + new byte[] { DSCode.CacheableBytes, 0x03, 0x01, 0x02, 0x03 }, + SerializationTestHelpers.Encode(new byte[] { 1, 2, 3 })); + } + + [Fact] + public void Encode_medium_array_writes_u16_length() + { + // Length 300 > 252 → 3-byte length prefix (0xFE + u16). + var value = new byte[300]; + for (var i = 0; i < value.Length; i++) value[i] = (byte)i; + + var encoded = SerializationTestHelpers.Encode(value); + + Assert.Equal(DSCode.CacheableBytes, encoded[0]); + Assert.Equal(0xFE, encoded[1]); // u16-length marker + Assert.Equal(new byte[] { 0x01, 0x2C }, encoded[2..4]); // u16 300 + Assert.Equal(1 + 3 + 300, encoded.Length); + Assert.Equal(value, encoded[4..]); + } + + [Fact] + public void Encode_large_array_writes_i32_length() + { + // Length > 0xFFFF → 5-byte length prefix (0xFD + i32). + var value = new byte[70000]; + + var encoded = SerializationTestHelpers.Encode(value); + + Assert.Equal(DSCode.CacheableBytes, encoded[0]); + Assert.Equal(0xFD, encoded[1]); // i32-length marker + Assert.Equal(new byte[] { 0x00, 0x01, 0x11, 0x70 }, encoded[2..6]); // i32 70000 + Assert.Equal(1 + 5 + 70000, encoded.Length); + } + + [Fact] + public void Decode_zero_length_returns_empty_array() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableBytes, 0x00 }); + Assert.Equal(Array.Empty(), result); + } + + [Fact] + public void Decode_inline_length_returns_byte_array() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableBytes, 0x03, 0xDE, 0xAD, 0xBE }); + Assert.Equal(new byte[] { 0xDE, 0xAD, 0xBE }, result); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new byte[] { 0 })] + [InlineData(new byte[] { 1, 2, 3 })] + [InlineData(new byte[] { 0xFF, 0x00, 0x80, 0x7F })] // signed-boundary mix + public void RoundTrip_small(byte[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + [Fact] + public void RoundTrip_252_byte_boundary() + { + // 252 = the highest 1-byte VL length. + var value = new byte[252]; + for (var i = 0; i < value.Length; i++) value[i] = (byte)i; + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_253_byte_boundary() + { + // 253 = first length forcing the 3-byte VL prefix. + var value = new byte[253]; + for (var i = 0; i < value.Length; i++) value[i] = (byte)i; + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_65536_byte_boundary() + { + // 65536 = first length forcing the 5-byte VL prefix. + var value = new byte[65536]; + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/CharArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/CharArrayDataConverterTests.cs new file mode 100644 index 0000000..d88e18d --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/CharArrayDataConverterTests.cs @@ -0,0 +1,49 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class CharArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CharArray, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_u16_be_per_ascii_char() + { + // {'A','B'} → DSCode + length-2 + 0x0041 / 0x0042. + Assert.Equal( + new byte[] { DSCode.CharArray, 0x02, 0x00, 0x41, 0x00, 0x42 }, + SerializationTestHelpers.Encode(new[] { 'A', 'B' })); + } + + [Fact] + public void Encode_writes_u16_be_for_cjk_code_unit() + { + // '中' = U+4E2D → wire 0x4E 0x2D. + Assert.Equal( + new byte[] { DSCode.CharArray, 0x01, 0x4E, 0x2D }, + SerializationTestHelpers.Encode(new[] { '中' })); + } + + [Fact] + public void Decode_reads_chars_back_in_order() + { + var result = (char[])SerializationTestHelpers.Decode( + new byte[] { DSCode.CharArray, 0x02, 0x00, 0x41, 0x4E, 0x2D })!; + Assert.Equal(new[] { 'A', '中' }, result); + } + + [Theory] + [InlineData(new[] { '\0' })] // NUL code unit survives + [InlineData(new[] { 'A', 'B', 'C' })] + [InlineData(new[] { '中', '文' })] + [InlineData(new[] { '￿' })] // max u16 code unit + public void RoundTrip(char[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/CharacterDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/CharacterDataConverterTests.cs new file mode 100644 index 0000000..5275586 Binary files /dev/null and b/tests/Geode.Client.Tests/Protocol/Serialization/CharacterDataConverterTests.cs differ diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/DateTimeDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/DateTimeDataConverterTests.cs new file mode 100644 index 0000000..135b519 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/DateTimeDataConverterTests.cs @@ -0,0 +1,129 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class DateTimeDataConverterTests +{ + [Fact] + public void Encode_unix_epoch_writes_dscode_and_zero_ms() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableDate, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + SerializationTestHelpers.Encode(DateTime.UnixEpoch)); + } + + [Fact] + public void Encode_one_millisecond_after_epoch() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableDate, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + SerializationTestHelpers.Encode( + DateTime.UnixEpoch.AddMilliseconds(1))); + } + + [Fact] + public void Encode_one_millisecond_before_epoch() + { + // -1 ms = 0xFFFF_FFFF_FFFF_FFFF as signed i64. + Assert.Equal( + new byte[] + { + DSCode.CacheableDate, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }, + SerializationTestHelpers.Encode( + DateTime.UnixEpoch.AddMilliseconds(-1))); + } + + [Fact] + public void Encode_truncates_sub_millisecond_ticks() + { + // 0.9 ms = 9000 ticks; should truncate (not round) to 0 ms. + var nineTenthsOfAMs = DateTime.UnixEpoch.AddTicks(9000); + Assert.Equal( + new byte[] + { + DSCode.CacheableDate, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + SerializationTestHelpers.Encode(nineTenthsOfAMs)); + } + + [Fact] + public void Encode_unspecified_kind_throws() + { + var unspecified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + var ex = Assert.Throws(() => + SerializationTestHelpers.Encode(unspecified)); + Assert.Contains("Unspecified", ex.Message); + } + + [Fact] + public void Encode_local_kind_converts_to_utc() + { + // Pick a specific UTC instant and express it via Local — the + // wire bytes must match the UTC representation regardless of + // the host's local time zone offset. + var utc = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var local = utc.ToLocalTime(); + + Assert.Equal( + SerializationTestHelpers.Encode(utc), + SerializationTestHelpers.Encode(local)); + } + + [Fact] + public void Decode_returns_utc_kind() + { + var result = (DateTime)SerializationTestHelpers.Decode(new byte[] + { + DSCode.CacheableDate, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + })!; + + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(DateTime.UnixEpoch, result); + } + + [Fact] + public void RoundTrip_utc_value_preserves_kind_and_instant() + { + var input = new DateTime(2026, 5, 12, 14, 30, 45, DateTimeKind.Utc); + var result = SerializationTestHelpers.RoundTrip(input); + + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(input, result); + } + + [Fact] + public void RoundTrip_local_value_returns_same_instant_as_utc() + { + var utc = new DateTime(2026, 5, 12, 14, 30, 45, DateTimeKind.Utc); + var local = utc.ToLocalTime(); + var result = SerializationTestHelpers.RoundTrip(local); + + // Read returns Utc Kind; the instant matches the original. + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(utc, result); + } + + [Fact] + public void RoundTrip_pre_epoch_date() + { + // 1969-12-31 23:59:59 UTC — 1 second before epoch. + var input = DateTime.UnixEpoch.AddSeconds(-1); + var result = SerializationTestHelpers.RoundTrip(input); + + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(input, result); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/DictionaryDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/DictionaryDataConverterTests.cs new file mode 100644 index 0000000..ea03fb5 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/DictionaryDataConverterTests.cs @@ -0,0 +1,131 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class DictionaryDataConverterTests +{ + [Fact] + public void Encode_empty_map_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableHashMap, 0x00 }, + SerializationTestHelpers.Encode(new Dictionary())); + } + + [Fact] + public void Encode_single_entry_interleaves_key_then_value() + { + // Single entry → deterministic wire. DSCode 67 + length 1 + + // (key DSCode 57 + i32 BE) + (value DSCode 87 + u16 len + 'A'). + Assert.Equal( + new byte[] + { + DSCode.CacheableHashMap, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x05, // key 5 + DSCode.CacheableASCIIString, 0x00, 0x01, 0x41, // value "A" + }, + SerializationTestHelpers.Encode( + new Dictionary { [5] = "A" })); + } + + [Fact] + public void Encode_null_value_routes_through_NullObj() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableHashMap, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x05, // key + DSCode.NullObj, // null value + }, + SerializationTestHelpers.Encode( + new Dictionary { [5] = null })); + } + + [Fact] + public void Decode_zero_length_returns_empty_canonical_dict() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableHashMap, 0x00 }); + var typed = Assert.IsType>(result); + Assert.Empty(typed); + } + + [Fact] + public void Decode_null_key_throws_GeodeException() + { + // Java HashMap permits one null key; Dictionary does not. Hand-craft a wire payload that puts + // DSCode.NullObj in the key slot and assert the converter + // refuses with a clear message rather than ArgumentNullException + // from Dictionary.Add. + var nullKeyWire = new byte[] + { + DSCode.CacheableHashMap, 0x01, + DSCode.NullObj, // null key + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x07, // value 7 + }; + + var ex = Assert.Throws( + () => SerializationTestHelpers.Decode(nullKeyWire)); + Assert.Contains("null key", ex.Message); + } + + [Fact] + public void RoundTrip_returns_canonical_Dictionary_object_object() + { + // Wire format does not encode K/V type. Decode always returns + // Dictionary; TypedResultAdapter handles the + // shape conversion afterwards. + var value = new Dictionary + { + [1] = "alpha", + [2] = "beta", + }; + var decoded = SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value)); + var typed = Assert.IsType>(decoded); + // Order non-deterministic; assert pair-equal. + Assert.Equal(2, typed.Count); + Assert.Equal("alpha", typed[1]); + Assert.Equal("beta", typed[2]); + } + + [Fact] + public void RoundTrip_with_null_value_preserved() + { + var value = new Dictionary + { + [1] = "a", + [2] = null, + [3] = "c", + }; + var decoded = (Dictionary)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(3, decoded.Count); + Assert.Equal("a", decoded[1]); + Assert.Null(decoded[2]); + Assert.Equal("c", decoded[3]); + } + + [Fact] + public void RoundTrip_mixed_key_and_value_types() + { + // Each key + value runs its runtime-type converter + // independently. Heterogeneous Dictionary + // survives the wire. + var value = new Dictionary + { + ["k1"] = 42, + [2] = "v2", + [true] = false, + }; + var decoded = (Dictionary)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(3, decoded.Count); + Assert.Equal(42, decoded["k1"]); + Assert.Equal("v2", decoded[2]); + Assert.Equal(false, decoded[true]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/DoubleArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/DoubleArrayDataConverterTests.cs new file mode 100644 index 0000000..e9e59c6 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/DoubleArrayDataConverterTests.cs @@ -0,0 +1,59 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class DoubleArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableDoubleArray, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_ieee754_be_per_element() + { + // 1.0 → 0x3FF0_0000_0000_0000, -1.0 → 0xBFF0_0000_0000_0000. + Assert.Equal( + new byte[] + { + DSCode.CacheableDoubleArray, 0x02, + 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xBF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + SerializationTestHelpers.Encode(new[] { 1.0, -1.0 })); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new[] { 0.0 })] + [InlineData(new[] { 1.0, -1.0, 3.141592653589793 })] + [InlineData(new[] { double.MinValue, double.MaxValue, double.Epsilon })] + public void RoundTrip(double[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + [Fact] + public void RoundTrip_preserves_nan_and_infinities() + { + var value = new[] + { + double.NaN, + double.PositiveInfinity, + double.NegativeInfinity, + }; + var result = SerializationTestHelpers.RoundTrip(value); + Assert.Equal(3, result.Length); + Assert.True(double.IsNaN(result[0])); + Assert.Equal(double.PositiveInfinity, result[1]); + Assert.Equal(double.NegativeInfinity, result[2]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/DoubleDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/DoubleDataConverterTests.cs new file mode 100644 index 0000000..f0565e3 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/DoubleDataConverterTests.cs @@ -0,0 +1,72 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class DoubleDataConverterTests +{ + [Fact] + public void Encode_zero_writes_dscode_and_eight_zero_bytes() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableDouble, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + SerializationTestHelpers.Encode(0d)); + } + + [Fact] + public void Encode_one_writes_ieee754_big_endian() + { + // 1.0d = 0x3FF0000000000000. + Assert.Equal( + new byte[] + { + DSCode.CacheableDouble, + 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + SerializationTestHelpers.Encode(1.0d)); + } + + [Fact] + public void Decode_reads_ieee754_value() + { + // -2.0d = 0xC000000000000000. + var result = SerializationTestHelpers.Decode(new byte[] + { + DSCode.CacheableDouble, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }); + Assert.Equal(-2.0d, result); + } + + [Theory] + [InlineData(0d)] + [InlineData(1.0d)] + [InlineData(-1.0d)] + [InlineData(double.MinValue)] + [InlineData(double.MaxValue)] + [InlineData(double.Epsilon)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + public void RoundTrip(double value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + [Fact] + public void RoundTrip_negative_zero_preserves_sign_bit() + { + var result = SerializationTestHelpers.RoundTrip(-0.0d); + Assert.Equal( + BitConverter.DoubleToInt64Bits(-0.0d), + BitConverter.DoubleToInt64Bits(result)); + } + + [Fact] + public void RoundTrip_nan_preserves_nan() + { + var result = SerializationTestHelpers.RoundTrip(double.NaN); + Assert.True(double.IsNaN(result)); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/HashSetDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/HashSetDataConverterTests.cs new file mode 100644 index 0000000..478cc26 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/HashSetDataConverterTests.cs @@ -0,0 +1,107 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class HashSetDataConverterTests +{ + [Fact] + public void Encode_empty_set_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableHashSet, 0x00 }, + SerializationTestHelpers.Encode(new HashSet())); + } + + [Fact] + public void Encode_single_int_element_predictable_bytes() + { + // Single element → deterministic wire (order question doesn't + // apply when N=1). DSCode 66 + length 1 + (DSCode 57 + i32 BE). + Assert.Equal( + new byte[] + { + DSCode.CacheableHashSet, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x07, + }, + SerializationTestHelpers.Encode(new HashSet { 7 })); + } + + [Fact] + public void Encode_single_string_routes_through_string_converter() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableHashSet, 0x01, + DSCode.CacheableASCIIString, 0x00, 0x01, 0x41, // "A" + }, + SerializationTestHelpers.Encode(new HashSet { "A" })); + } + + [Fact] + public void Decode_zero_length_returns_empty_canonical_set() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableHashSet, 0x00 }); + var typed = Assert.IsType>(result); + Assert.Empty(typed); + } + + [Fact] + public void RoundTrip_returns_canonical_HashSet_object() + { + // Wire format does not encode container element type — decode + // always returns HashSet. Target-shape conversion to + // HashSet happens at TypedResultAdapter, not here. + var value = new HashSet { 1, 2, 3 }; + var decoded = SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value)); + var typed = Assert.IsType>(decoded); + // Set-equal comparison — wire iteration order is non- + // deterministic (cppcache unordered_set). + Assert.Equal( + new HashSet { 1, 2, 3 }, + typed); + } + + [Fact] + public void RoundTrip_with_null_element_survives() + { + // Java HashSet permits one null; HashSet mirrors + // that. cppcache's unordered_set doesn't but the wire wraps + // null via DSCode.NullObj uniformly. + var value = new HashSet { "a", null, "b" }; + var decoded = (HashSet)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(3, decoded.Count); + Assert.Contains("a", decoded); + Assert.Contains("b", decoded); + Assert.Contains(null, decoded); + } + + [Fact] + public void RoundTrip_string_set() + { + var value = new HashSet { "alpha", "beta", "gamma" }; + var decoded = (HashSet)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(3, decoded.Count); + Assert.Contains("alpha", decoded); + Assert.Contains("beta", decoded); + Assert.Contains("gamma", decoded); + } + + [Fact] + public void RoundTrip_mixed_element_types_per_element_dispatch() + { + // Each element's runtime type drives its own converter. + var value = new HashSet { 42, "hello", true }; + var decoded = (HashSet)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(3, decoded.Count); + Assert.Contains(42, decoded); + Assert.Contains("hello", decoded); + Assert.Contains(true, decoded); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/Int16ArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/Int16ArrayDataConverterTests.cs new file mode 100644 index 0000000..a7945af --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/Int16ArrayDataConverterTests.cs @@ -0,0 +1,46 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class Int16ArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt16Array, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_i16_be_per_element() + { + // {0x0102, -1} → length-2 + 0x01 0x02 / 0xFF 0xFF. + Assert.Equal( + new byte[] { DSCode.CacheableInt16Array, 0x02, 0x01, 0x02, 0xFF, 0xFF }, + SerializationTestHelpers.Encode(new short[] { 0x0102, -1 })); + } + + [Fact] + public void Decode_reads_signed_values() + { + var result = (short[])SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableInt16Array, 0x02, 0x80, 0x00, 0x7F, 0xFF })!; + Assert.Equal(new short[] { short.MinValue, short.MaxValue }, result); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new short[] { 0 })] + [InlineData(new short[] { 1, 2, 3 })] + [InlineData(new short[] { short.MinValue, -1, 0, 1, short.MaxValue })] + public void RoundTrip(short[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/Int16DataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/Int16DataConverterTests.cs new file mode 100644 index 0000000..53821b8 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/Int16DataConverterTests.cs @@ -0,0 +1,40 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class Int16DataConverterTests +{ + [Fact] + public void Encode_positive_writes_dscode_and_big_endian_bytes() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt16, 0x01, 0x02 }, + SerializationTestHelpers.Encode((short)0x0102)); + } + + [Fact] + public void Encode_negative_writes_two_complement() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt16, 0xFF, 0xFF }, + SerializationTestHelpers.Encode((short)-1)); + } + + [Fact] + public void Decode_reads_signed_value() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableInt16, 0x80, 0x00 }); + Assert.Equal(short.MinValue, result); + } + + [Theory] + [InlineData((short)0)] + [InlineData((short)1)] + [InlineData((short)-1)] + [InlineData(short.MinValue)] + [InlineData(short.MaxValue)] + public void RoundTrip(short value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/Int32ArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/Int32ArrayDataConverterTests.cs new file mode 100644 index 0000000..76121d2 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/Int32ArrayDataConverterTests.cs @@ -0,0 +1,85 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class Int32ArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt32Array, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_i32_be_per_element() + { + // {0x01020304, -1} → length-2 + 0x01 0x02 0x03 0x04 / 0xFF 0xFF 0xFF 0xFF. + Assert.Equal( + new byte[] + { + DSCode.CacheableInt32Array, 0x02, + 0x01, 0x02, 0x03, 0x04, + 0xFF, 0xFF, 0xFF, 0xFF, + }, + SerializationTestHelpers.Encode(new[] { 0x01020304, -1 })); + } + + [Fact] + public void Decode_reads_signed_values() + { + var result = (int[])SerializationTestHelpers.Decode( + new byte[] + { + DSCode.CacheableInt32Array, 0x02, + 0x80, 0x00, 0x00, 0x00, // int.MinValue + 0x7F, 0xFF, 0xFF, 0xFF, // int.MaxValue + })!; + Assert.Equal(new[] { int.MinValue, int.MaxValue }, result); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new[] { 0 })] + [InlineData(new[] { 1, 2, 3 })] + [InlineData(new[] { int.MinValue, -1, 0, 1, int.MaxValue })] + public void RoundTrip(int[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + // ── VL length boundaries ──────────────────────────────────── + // Exercised here (Int32Array) instead of every array type — the + // WriteArrayLen / ReadArrayLen path is shared with every other + // primitive array converter, so one boundary sweep suffices. + + [Fact] + public void RoundTrip_252_element_boundary() + { + // 252 = highest 1-byte VL length. + var value = Enumerable.Range(0, 252).ToArray(); + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_253_element_boundary() + { + // 253 = first length forcing the 3-byte VL prefix (0xFE + u16). + var value = Enumerable.Range(0, 253).ToArray(); + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_65536_element_boundary() + { + // 65536 = first length forcing the 5-byte VL prefix (0xFD + i32). + var value = Enumerable.Range(0, 65536).ToArray(); + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/Int32DataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/Int32DataConverterTests.cs new file mode 100644 index 0000000..6c4e899 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/Int32DataConverterTests.cs @@ -0,0 +1,40 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class Int32DataConverterTests +{ + [Fact] + public void Encode_positive_writes_dscode_and_big_endian_bytes() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt32, 0x01, 0x02, 0x03, 0x04 }, + SerializationTestHelpers.Encode(0x01020304)); + } + + [Fact] + public void Encode_negative_writes_two_complement() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt32, 0xFF, 0xFF, 0xFF, 0xFF }, + SerializationTestHelpers.Encode(-1)); + } + + [Fact] + public void Decode_reads_signed_value() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableInt32, 0x80, 0x00, 0x00, 0x00 }); + Assert.Equal(int.MinValue, result); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(-1)] + [InlineData(int.MinValue)] + [InlineData(int.MaxValue)] + public void RoundTrip(int value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/Int64ArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/Int64ArrayDataConverterTests.cs new file mode 100644 index 0000000..ea69b3f --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/Int64ArrayDataConverterTests.cs @@ -0,0 +1,56 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class Int64ArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableInt64Array, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_i64_be_per_element() + { + // {1, -1} → length-2 + 8 bytes per element BE. + Assert.Equal( + new byte[] + { + DSCode.CacheableInt64Array, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }, + SerializationTestHelpers.Encode(new long[] { 1, -1 })); + } + + [Fact] + public void Decode_reads_signed_min_max() + { + var result = (long[])SerializationTestHelpers.Decode( + new byte[] + { + DSCode.CacheableInt64Array, 0x02, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // long.MinValue + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // long.MaxValue + })!; + Assert.Equal(new[] { long.MinValue, long.MaxValue }, result); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new long[] { 0 })] + [InlineData(new long[] { 1, 2, 3 })] + [InlineData(new long[] { long.MinValue, -1, 0, 1, long.MaxValue })] + public void RoundTrip(long[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/Int64DataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/Int64DataConverterTests.cs new file mode 100644 index 0000000..dfae713 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/Int64DataConverterTests.cs @@ -0,0 +1,51 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class Int64DataConverterTests +{ + [Fact] + public void Encode_positive_writes_dscode_and_big_endian_bytes() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableInt64, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + }, + SerializationTestHelpers.Encode(0x0102030405060708L)); + } + + [Fact] + public void Encode_negative_writes_two_complement() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableInt64, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }, + SerializationTestHelpers.Encode(-1L)); + } + + [Fact] + public void Decode_reads_signed_value() + { + var result = SerializationTestHelpers.Decode(new byte[] + { + DSCode.CacheableInt64, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }); + Assert.Equal(long.MinValue, result); + } + + [Theory] + [InlineData(0L)] + [InlineData(1L)] + [InlineData(-1L)] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + public void RoundTrip(long value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/LinkedListDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/LinkedListDataConverterTests.cs new file mode 100644 index 0000000..23e60c6 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/LinkedListDataConverterTests.cs @@ -0,0 +1,99 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class LinkedListDataConverterTests +{ + [Fact] + public void Encode_empty_list_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableLinkedList, 0x00 }, + SerializationTestHelpers.Encode(new LinkedList())); + } + + [Fact] + public void Encode_int_elements_preserve_head_to_tail_order() + { + // Wire is deterministic — head→tail iteration matches + // ArrayList layout (cppcache backs both with std::vector). + var list = new LinkedList(); + list.AddLast(1); + list.AddLast(2); + list.AddLast(3); + + Assert.Equal( + new byte[] + { + DSCode.CacheableLinkedList, 0x03, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x02, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x03, + }, + SerializationTestHelpers.Encode(list)); + } + + [Fact] + public void Encode_null_element_routes_through_NullObj() + { + var list = new LinkedList(); + list.AddLast("A"); + // AddLast(null) is ambiguous between AddLast(T) and + // AddLast(LinkedListNode) — cast to disambiguate. + list.AddLast((string?)null); + + Assert.Equal( + new byte[] + { + DSCode.CacheableLinkedList, 0x02, + DSCode.CacheableASCIIString, 0x00, 0x01, 0x41, + DSCode.NullObj, + }, + SerializationTestHelpers.Encode(list)); + } + + [Fact] + public void Decode_zero_length_returns_empty_canonical_list() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableLinkedList, 0x00 }); + var typed = Assert.IsType>(result); + Assert.Empty(typed); + } + + [Fact] + public void RoundTrip_returns_canonical_LinkedList_object() + { + var value = new LinkedList(); + value.AddLast(10); + value.AddLast(20); + value.AddLast(30); + + var decoded = SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value)); + + // Canonical decode is LinkedList, NOT LinkedList + // — shape conversion happens at TypedResultAdapter. + var typed = Assert.IsType>(decoded); + Assert.Equal(new object?[] { 10, 20, 30 }, typed); // head→tail order + } + + [Fact] + public void RoundTrip_preserves_null_element_position() + { + var value = new LinkedList(); + value.AddLast("a"); + value.AddLast((string?)null); + value.AddLast("b"); + + var decoded = (LinkedList)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + + var array = decoded.ToArray(); + Assert.Equal(3, array.Length); + Assert.Equal("a", array[0]); + Assert.Null(array[1]); + Assert.Equal("b", array[2]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/ListDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/ListDataConverterTests.cs new file mode 100644 index 0000000..b595ef4 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/ListDataConverterTests.cs @@ -0,0 +1,128 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class ListDataConverterTests +{ + [Fact] + public void Encode_empty_list_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableArrayList, 0x00 }, + SerializationTestHelpers.Encode(new List())); + } + + [Fact] + public void Encode_int_elements_recurse_through_registry() + { + // {1,2,3} → DSCode 65 + length-3 + per-element (DSCode 57 + i32 BE). + Assert.Equal( + new byte[] + { + DSCode.CacheableArrayList, 0x03, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x02, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x03, + }, + SerializationTestHelpers.Encode(new List { 1, 2, 3 })); + } + + [Fact] + public void Encode_string_elements_route_through_string_converter() + { + // ASCII elements pick DSCode 87 (CacheableASCIIString). + Assert.Equal( + new byte[] + { + DSCode.CacheableArrayList, 0x02, + DSCode.CacheableASCIIString, 0x00, 0x01, 0x41, // "A" + DSCode.CacheableASCIIString, 0x00, 0x01, 0x42, // "B" + }, + SerializationTestHelpers.Encode(new List { "A", "B" })); + } + + [Fact] + public void Encode_null_element_routes_through_NullObj() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableArrayList, 0x02, + DSCode.CacheableASCIIString, 0x00, 0x01, 0x41, // "A" + DSCode.NullObj, // null slot + }, + SerializationTestHelpers.Encode(new List { "A", null })); + } + + [Fact] + public void Decode_zero_length_returns_empty_list() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableArrayList, 0x00 }); + var typed = Assert.IsType>(result); + Assert.Empty(typed); + } + + [Fact] + public void RoundTrip_returns_canonical_List_object() + { + // Wire format does not encode container element type — decode + // always returns List. Target-shape conversion + // (List / IList / …) happens at TypedResultAdapter, + // not in this converter. + var value = new List { 1, 2, 3 }; + var decoded = SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value)); + var typed = Assert.IsType>(decoded); + Assert.Equal(new object?[] { 1, 2, 3 }, typed); + } + + [Fact] + public void RoundTrip_with_null_elements_preserves_positions() + { + var value = new List { "a", null, "b", null }; + var decoded = (List)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(4, decoded.Count); + Assert.Equal("a", decoded[0]); + Assert.Null(decoded[1]); + Assert.Equal("b", decoded[2]); + Assert.Null(decoded[3]); + } + + [Fact] + public void RoundTrip_nested_lists() + { + // List> → outer iterates, inner re-enters this same + // converter via the open-generic fallback. Decoded shape is + // List of List. + var value = new List> + { + new() { 1, 2 }, + new() { 3, 4 }, + }; + var decoded = (List)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(2, decoded.Count); + var inner0 = Assert.IsType>(decoded[0]); + var inner1 = Assert.IsType>(decoded[1]); + Assert.Equal(new object?[] { 1, 2 }, inner0); + Assert.Equal(new object?[] { 3, 4 }, inner1); + } + + [Fact] + public void RoundTrip_mixed_element_types_per_element_dispatch() + { + // Each element's runtime type drives its own converter — int, + // string, bool, null all coexist in one List. + var value = new List { 42, "hello", true, null }; + var decoded = (List)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(4, decoded.Count); + Assert.Equal(42, decoded[0]); + Assert.Equal("hello", decoded[1]); + Assert.Equal(true, decoded[2]); + Assert.Null(decoded[3]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryDepthTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryDepthTests.cs new file mode 100644 index 0000000..85ec2b5 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryDepthTests.cs @@ -0,0 +1,163 @@ +using System.Buffers; +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +/// +/// Depth-limit unit tests for . The +/// limit defends both the read and write paths from stack-overflow DoS +/// via deeply nested wire payloads; same threat model and default +/// (64) as . +/// +public class SerializationRegistryDepthTests +{ + [Fact] + public void MaxDepth_default_is_64() + { + var registry = SerializationTestHelpers.CreateRegistry(); + Assert.Equal(64, registry.MaxDepth); + } + + // ── Write side ───────────────────────────────────────────── + + [Fact] + public async Task Write_within_depth_budget_succeeds() + { + // MaxDepth=3 leaves room for depths 0, 1, 2. List> + // uses depth 0 (outer), 1 (inner List), 2 (int element) — all + // strictly less than 3. + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 3); + var value = new List> { new() { 1, 2 } }; + using var writer = new DataOutput(); + + await registry.WriteObjectAsync(writer, value, ct: TestContext.Current.CancellationToken); + + Assert.NotEmpty(writer.WrittenSpan.ToArray()); + } + + [Fact] + public async Task Write_exceeding_max_depth_throws_InvalidOperationException() + { + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 2); + var value = new List> { new() { 1 } }; + using var writer = new DataOutput(); + + var ex = await Assert.ThrowsAsync( + async () => await registry.WriteObjectAsync(writer, value, ct: TestContext.Current.CancellationToken)); + Assert.Contains("MaxDepth", ex.Message); + Assert.Contains("2", ex.Message); + } + + [Fact] + public async Task Write_top_level_scalar_at_max_depth_one_succeeds() + { + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 1); + using var writer = new DataOutput(); + + await registry.WriteObjectAsync(writer, 42, ct: TestContext.Current.CancellationToken); + + Assert.NotEmpty(writer.WrittenSpan.ToArray()); + } + + [Fact] + public async Task Write_any_container_at_max_depth_one_throws() + { + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 1); + using var writer = new DataOutput(); + + await Assert.ThrowsAsync( + async () => await registry.WriteObjectAsync(writer, new List { 1 }, ct: TestContext.Current.CancellationToken)); + } + + // ── Read side ────────────────────────────────────────────── + + [Fact] + public void Read_within_depth_budget_succeeds() + { + // Hand-built wire: List>{ { 7 } }. Reader uses + // depths 0 (outer), 1 (inner), 2 (int) — fits MaxDepth=3. + var wire = new byte[] + { + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x07, + }; + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 3); + var reader = new DataInput(wire); + + var result = registry.ReadObject(reader); + + var outer = Assert.IsType>(result); + Assert.Single(outer); + var inner = Assert.IsType>(outer[0]); + Assert.Single(inner); + Assert.Equal(7, inner[0]); + } + + [Fact] + public void Read_exceeding_max_depth_throws_GeodeException() + { + // Same nested-2 payload but MaxDepth=2 — the int element entry + // at depth=2 fails (2 >= 2). Wire-level error → GeodeException + // (the server / wire produced something we refuse to consume). + var wire = new byte[] + { + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x07, + }; + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 2); + var reader = new DataInput(wire); + + var ex = Assert.Throws( + () => registry.ReadObject(reader)); + Assert.Contains("MaxDepth", ex.Message); + Assert.Contains("2", ex.Message); + } + + [Fact] + public void Read_top_level_scalar_at_max_depth_one_succeeds() + { + var wire = new byte[] { DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x2A }; + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 1); + var reader = new DataInput(wire); + + Assert.Equal(42, registry.ReadObject(reader)); + } + + [Fact] + public void Read_any_container_at_max_depth_one_throws() + { + // MaxDepth=1: wire [65, 1, 57, 0,0,0,7] = List{ 7 }. The + // int element entry at depth=1 fails (1 >= 1). + var wire = new byte[] + { + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x07, + }; + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 1); + var reader = new DataInput(wire); + + Assert.Throws(() => registry.ReadObject(reader)); + } + + // ── Symmetry: encode at the limit feeds decode at the same limit ── + + [Fact] + public async Task Encode_then_decode_round_trips_at_the_exact_limit() + { + var registry = SerializationTestHelpers.CreateRegistry(maxDepth: 3); + + using var writer = new DataOutput(); + await registry.WriteObjectAsync(writer, new List> { new() { 7 } }, ct: TestContext.Current.CancellationToken); + + var reader = new DataInput(writer.WrittenSpan.ToArray()); + var result = registry.ReadObject(reader); + + var outer = Assert.IsType>(result); + var inner = Assert.IsType>(outer[0]); + Assert.Equal(7, inner[0]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryLengthTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryLengthTests.cs new file mode 100644 index 0000000..bb6d205 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryLengthTests.cs @@ -0,0 +1,230 @@ +using System.Buffers; +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +/// +/// Length-limit unit tests for the +/// / +/// / +/// trio. +/// Defends both read and write paths against pre-allocation DoS where a +/// hostile wire length-prefix would force a gigabyte-scale allocation. +/// +/// +/// One representative converter per dispatch path: a primitive array +/// ( — CacheScopeContext-direct +/// for ), a +/// collection ( — registry-snapshot +/// for the same limit), (separate +/// ), and +/// (separate +/// with +/// per-DSCode branches). The pattern is identical across the other +/// converters, so per-converter exhaustive coverage would be +/// duplicative. +/// +public class SerializationRegistryLengthTests +{ + // ── Primitive array (CacheScopeContext-direct path) ──────── + + [Fact] + public async Task Int32Array_write_at_limit_succeeds() + { + var registry = SerializationTestHelpers.CreateRegistry(maxArrayLength: 3); + using var writer = new DataOutput(); + + await registry.WriteObjectAsync(writer, new[] { 1, 2, 3 }, ct: TestContext.Current.CancellationToken); + + Assert.NotEmpty(writer.WrittenSpan.ToArray()); + } + + [Fact] + public async Task Int32Array_write_over_limit_throws_InvalidOperationException() + { + var registry = SerializationTestHelpers.CreateRegistry(maxArrayLength: 3); + using var writer = new DataOutput(); + + var ex = await Assert.ThrowsAsync( + async () => await registry.WriteObjectAsync(writer, new[] { 1, 2, 3, 4 }, ct: TestContext.Current.CancellationToken)); + Assert.Contains("MaxArrayLength", ex.Message); + Assert.Contains("4", ex.Message); + Assert.Contains("3", ex.Message); + } + + [Fact] + public void Int32Array_read_over_limit_throws_GeodeException() + { + // Wire claims length 4, configured limit is 3 — payload bytes + // never get read past the length-prefix because the check + // fires before allocation. + var wire = new byte[] + { + DSCode.CacheableInt32Array, 0x04, + // No payload — converter throws before reading any element. + }; + var registry = SerializationTestHelpers.CreateRegistry(maxArrayLength: 3); + var reader = new DataInput(wire); + + var ex = Assert.Throws(() => registry.ReadObject(reader)); + Assert.Contains("MaxArrayLength", ex.Message); + Assert.Contains("4", ex.Message); + } + + // ── Collection (registry-snapshot path) ──────────────────── + + [Fact] + public async Task List_write_over_limit_throws_InvalidOperationException() + { + var registry = SerializationTestHelpers.CreateRegistry(maxArrayLength: 3); + using var writer = new DataOutput(); + + var ex = await Assert.ThrowsAsync( + async () => await registry.WriteObjectAsync(writer, new List { 1, 2, 3, 4 }, ct: TestContext.Current.CancellationToken)); + Assert.Contains("MaxArrayLength", ex.Message); + } + + [Fact] + public void List_read_over_limit_throws_GeodeException() + { + var wire = new byte[] + { + DSCode.CacheableArrayList, 0x04, + }; + var registry = SerializationTestHelpers.CreateRegistry(maxArrayLength: 3); + var reader = new DataInput(wire); + + var ex = Assert.Throws(() => registry.ReadObject(reader)); + Assert.Contains("MaxArrayLength", ex.Message); + } + + // ── byte[] (separate MaxBytesLength) ─────────────────────── + + [Fact] + public async Task Bytes_uses_MaxBytesLength_not_MaxArrayLength() + { + var registry = SerializationTestHelpers.CreateRegistry( + maxArrayLength: 3, + maxBytesLength: 10); + using var writer = new DataOutput(); + + await registry.WriteObjectAsync(writer, new byte[] { 1, 2, 3, 4, 5 }, ct: TestContext.Current.CancellationToken); + + Assert.NotEmpty(writer.WrittenSpan.ToArray()); + } + + [Fact] + public async Task Bytes_write_over_limit_throws_InvalidOperationException() + { + var registry = SerializationTestHelpers.CreateRegistry(maxBytesLength: 4); + using var writer = new DataOutput(); + + var ex = await Assert.ThrowsAsync( + async () => await registry.WriteObjectAsync(writer, new byte[] { 1, 2, 3, 4, 5 }, ct: TestContext.Current.CancellationToken)); + Assert.Contains("MaxBytesLength", ex.Message); + } + + [Fact] + public void Bytes_read_over_limit_throws_GeodeException() + { + var wire = new byte[] + { + DSCode.CacheableBytes, 0x05, + // payload omitted — check fires before consuming bytes + }; + var registry = SerializationTestHelpers.CreateRegistry(maxBytesLength: 4); + var reader = new DataInput(wire); + + var ex = Assert.Throws(() => registry.ReadObject(reader)); + Assert.Contains("MaxBytesLength", ex.Message); + } + + // ── String (MaxStringLength, multi-DSCode) ───────────────── + + [Fact] + public async Task String_write_over_limit_throws_InvalidOperationException() + { + var registry = SerializationTestHelpers.CreateRegistry(maxStringLength: 3); + using var writer = new DataOutput(); + + var ex = await Assert.ThrowsAsync( + async () => await registry.WriteObjectAsync(writer, "abcd", ct: TestContext.Current.CancellationToken)); + Assert.Contains("MaxStringLength", ex.Message); + } + + [Fact] + public async Task String_at_limit_succeeds() + { + var registry = SerializationTestHelpers.CreateRegistry(maxStringLength: 3); + using var writer = new DataOutput(); + + await registry.WriteObjectAsync(writer, "abc", ct: TestContext.Current.CancellationToken); + + Assert.NotEmpty(writer.WrittenSpan.ToArray()); + } + + [Fact] + public void String_read_ascii_short_over_limit_throws_GeodeException() + { + // DSCode 87 (CacheableASCIIString): u16 length. Wire claims + // length 10, configured limit is 3. + var wire = new byte[] + { + DSCode.CacheableASCIIString, 0x00, 0x0A, + // payload omitted — check fires before reading chars + }; + var registry = SerializationTestHelpers.CreateRegistry(maxStringLength: 3); + var reader = new DataInput(wire); + + var ex = Assert.Throws(() => registry.ReadObject(reader)); + Assert.Contains("MaxStringLength", ex.Message); + } + + [Fact] + public void String_read_ascii_huge_over_limit_throws_GeodeException() + { + // DSCode 88 (CacheableASCIIStringHuge): i32 length — the main + // attack surface (can be int.MaxValue from a hostile server). + var wire = new byte[] + { + DSCode.CacheableASCIIStringHuge, 0x00, 0x01, 0x00, 0x00, // length 65536 + }; + var registry = SerializationTestHelpers.CreateRegistry(maxStringLength: 100); + var reader = new DataInput(wire); + + var ex = Assert.Throws(() => registry.ReadObject(reader)); + Assert.Contains("MaxStringLength", ex.Message); + } + + [Fact] + public void String_read_utf16_huge_over_limit_throws_GeodeException() + { + // DSCode 89 (CacheableStringHuge): i32 char-count, UTF-16 BE. + // Same attack surface as 88. + var wire = new byte[] + { + DSCode.CacheableStringHuge, 0x00, 0x01, 0x00, 0x00, // length 65536 + }; + var registry = SerializationTestHelpers.CreateRegistry(maxStringLength: 100); + var reader = new DataInput(wire); + + var ex = Assert.Throws(() => registry.ReadObject(reader)); + Assert.Contains("MaxStringLength", ex.Message); + } + + // ── Snapshot defaults ────────────────────────────────────── + + [Fact] + public void Defaults_match_production() + { + var registry = SerializationTestHelpers.CreateRegistry(); + + Assert.Equal(1_000_000, registry.MaxArrayLength); + Assert.Equal(1_000_000, registry.MaxStringLength); + // MaxBytesLength is consumed via CacheScopeContext directly by + // BytesDataConverter — not snapshot on the registry — so no + // assertion here. Validator-default test covers the 10M value. + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryTests.cs new file mode 100644 index 0000000..5a2681e --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationRegistryTests.cs @@ -0,0 +1,72 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class SerializationRegistryTests +{ + [Fact] + public void WriteObject_dispatches_closed_List_int_via_open_generic_fallback() + { + // List is not registered as a closed type in _byType; the + // dispatch falls back to GetGenericTypeDefinition() which hits + // _byType[typeof(List<>)] = ListDataConverter. + Assert.Equal( + new byte[] + { + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x07, + }, + SerializationTestHelpers.Encode(new List { 7 })); + } + + [Fact] + public void WriteObject_dispatches_closed_List_string_via_same_open_generic() + { + // Same converter instance handles every closed List — the + // open-generic registration is shared. + Assert.Equal( + new byte[] + { + DSCode.CacheableArrayList, 0x01, + DSCode.CacheableASCIIString, 0x00, 0x01, 0x58, // "X" + }, + SerializationTestHelpers.Encode(new List { "X" })); + } + + [Fact] + public void WriteObject_unregistered_closed_generic_still_throws_NotSupported() + { + // SortedDictionary<,> has no registered converter — open- + // generic fallback probes typeof(SortedDictionary<,>), misses + // even though Dictionary<,> IS registered (different open- + // generic identity), and the unregistered-type branch fires. + Assert.Throws( + () => SerializationTestHelpers.Encode(new SortedDictionary())); + } + + [Fact] + public void WriteObject_existing_scalar_path_unchanged_by_open_generic_fallback() + { + // Regression guard: scalars (non-generic types) still hit + // _byType on the first lookup; fallback only triggers when + // type.IsGenericType. + Assert.Equal( + new byte[] { DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x2A }, + SerializationTestHelpers.Encode(42)); + } + + [Fact] + public void WriteObject_existing_array_path_unchanged_by_open_generic_fallback() + { + // int[] is a concrete (non-generic) type registered in _byType + // directly — closed lookup hits without touching the fallback. + Assert.Equal( + new byte[] + { + DSCode.CacheableInt32Array, 0x01, + 0x00, 0x00, 0x00, 0x07, + }, + SerializationTestHelpers.Encode(new[] { 7 })); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/SerializationTestHelpers.cs b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationTestHelpers.cs new file mode 100644 index 0000000..6681c1d --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/SerializationTestHelpers.cs @@ -0,0 +1,118 @@ +using Geode.Client.Protocol; +using Geode.Client.Protocol.Serialization; +using Geode.Client.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Geode.Client.Tests.Protocol.Serialization; + +/// +/// Wire-level helpers for converter tests. Builds a minimal +/// + an uninitialised +/// instance so tests can roundtrip values +/// through the same production uses. +/// +/// +/// Each / +/// / +/// call spins up a +/// fresh SP + cache + registry. Limit-bound converters (Bytes / String / +/// every Array*) snapshot the cap into a private field at first access, +/// so callers MUST pass overrides via these helpers — mutating +/// after the registry has +/// materialised is a no-op for the existing converter instances. +/// +internal static class SerializationTestHelpers +{ + /// Default DI host with logging stubs + AddGeodeFactory. + public static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + /// + /// Build a directly via + /// (skipping + /// — the registry only + /// needs the cache instance + its ). + /// Limits are applied BEFORE the + /// Lazy materialises so the per-converter snapshot picks them up. + /// + public static GeodeCache CreateCache( + IServiceProvider sp, + int maxDepth = 64, + int maxArrayLength = 1_000_000, + int maxBytesLength = 10_000_000, + int maxStringLength = 1_000_000) + { + var cache = ActivatorUtilities.CreateInstance(sp, "test-cache"); + cache.CacheProperties.MaxDepth = maxDepth; + cache.CacheProperties.MaxArrayLength = maxArrayLength; + cache.CacheProperties.MaxBytesLength = maxBytesLength; + cache.CacheProperties.MaxStringLength = maxStringLength; + return cache; + } + + /// Shorthand: build SP + cache + return the registry. + public static SerializationRegistry CreateRegistry( + int maxDepth = 64, + int maxArrayLength = 1_000_000, + int maxBytesLength = 10_000_000, + int maxStringLength = 1_000_000) + { + var sp = BuildSp(); + var cache = CreateCache(sp, maxDepth, maxArrayLength, maxBytesLength, maxStringLength); + return cache.SerializationRegistry; + } + + /// + /// Encode through the registry and return + /// the full wire bytes (DSCode + payload). + /// + /// + /// Test-only sync facade — blocks on the async write. Production + /// callers all go through ; + /// the built-in converters do CPU work and return + /// , so no deadlock risk. + /// + public static byte[] Encode(object? value, + int maxDepth = 64, + int maxArrayLength = 1_000_000, + int maxBytesLength = 10_000_000, + int maxStringLength = 1_000_000) + { + var registry = CreateRegistry(maxDepth, maxArrayLength, maxBytesLength, maxStringLength); + using var writer = new DataOutput(); + registry.WriteObjectAsync(writer, value).AsTask().GetAwaiter().GetResult(); + return writer.WrittenSpan.ToArray(); + } + + /// + /// Decode wire bytes through the registry. Bytes must start with a + /// DSCode the registry can dispatch on. + /// + public static object? Decode(byte[] bytes, + int maxDepth = 64, + int maxArrayLength = 1_000_000, + int maxBytesLength = 10_000_000, + int maxStringLength = 1_000_000) + { + var registry = CreateRegistry(maxDepth, maxArrayLength, maxBytesLength, maxStringLength); + var reader = new DataInput(bytes); + return registry.ReadObject(reader); + } + + /// Encode then decode, casting back to . + public static T RoundTrip(T value, + int maxDepth = 64, + int maxArrayLength = 1_000_000, + int maxBytesLength = 10_000_000, + int maxStringLength = 1_000_000) => + (T)Decode(Encode(value!, maxDepth, maxArrayLength, maxBytesLength, maxStringLength), + maxDepth, maxArrayLength, maxBytesLength, maxStringLength)!; +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/SingleArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/SingleArrayDataConverterTests.cs new file mode 100644 index 0000000..2af816f --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/SingleArrayDataConverterTests.cs @@ -0,0 +1,62 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class SingleArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableFloatArray, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_writes_ieee754_be_per_element() + { + // 1.0f → 0x3F800000, -1.0f → 0xBF800000. + Assert.Equal( + new byte[] + { + DSCode.CacheableFloatArray, 0x02, + 0x3F, 0x80, 0x00, 0x00, + 0xBF, 0x80, 0x00, 0x00, + }, + SerializationTestHelpers.Encode(new[] { 1.0f, -1.0f })); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Theory] + [InlineData(new[] { 0f })] + [InlineData(new[] { 1f, -1f, 3.14159f })] + [InlineData(new[] { float.MinValue, float.MaxValue, float.Epsilon })] + public void RoundTrip(float[] value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + [Fact] + public void RoundTrip_preserves_nan_and_infinities() + { + // BitConverter equality survives NaN / ±Infinity exactly; the + // generic Assert.Equal float comparison treats NaN ≠ NaN, so + // compare element-by-element with float.IsNaN where needed. + var value = new[] + { + float.NaN, + float.PositiveInfinity, + float.NegativeInfinity, + }; + var result = SerializationTestHelpers.RoundTrip(value); + Assert.Equal(3, result.Length); + Assert.True(float.IsNaN(result[0])); + Assert.Equal(float.PositiveInfinity, result[1]); + Assert.Equal(float.NegativeInfinity, result[2]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/SingleDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/SingleDataConverterTests.cs new file mode 100644 index 0000000..57513e3 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/SingleDataConverterTests.cs @@ -0,0 +1,62 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class SingleDataConverterTests +{ + [Fact] + public void Encode_zero_writes_dscode_and_four_zero_bytes() + { + Assert.Equal( + new byte[] { DSCode.CacheableFloat, 0x00, 0x00, 0x00, 0x00 }, + SerializationTestHelpers.Encode(0f)); + } + + [Fact] + public void Encode_one_writes_ieee754_big_endian() + { + // 1.0f = 0x3F800000. + Assert.Equal( + new byte[] { DSCode.CacheableFloat, 0x3F, 0x80, 0x00, 0x00 }, + SerializationTestHelpers.Encode(1.0f)); + } + + [Fact] + public void Decode_reads_ieee754_value() + { + // -2.0f = 0xC0000000. + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableFloat, 0xC0, 0x00, 0x00, 0x00 }); + Assert.Equal(-2.0f, result); + } + + [Theory] + [InlineData(0f)] + [InlineData(1.0f)] + [InlineData(-1.0f)] + [InlineData(float.MinValue)] + [InlineData(float.MaxValue)] + [InlineData(float.Epsilon)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void RoundTrip(float value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + [Fact] + public void RoundTrip_negative_zero_preserves_sign_bit() + { + var result = SerializationTestHelpers.RoundTrip(-0.0f); + // -0.0f == 0.0f under operator==, so compare via bit pattern. + Assert.Equal( + BitConverter.SingleToInt32Bits(-0.0f), + BitConverter.SingleToInt32Bits(result)); + } + + [Fact] + public void RoundTrip_nan_preserves_nan() + { + var result = SerializationTestHelpers.RoundTrip(float.NaN); + Assert.True(float.IsNaN(result)); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/StackDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/StackDataConverterTests.cs new file mode 100644 index 0000000..3b3f10e --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/StackDataConverterTests.cs @@ -0,0 +1,120 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class StackDataConverterTests +{ + [Fact] + public void Encode_empty_stack_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableStack, 0x00 }, + SerializationTestHelpers.Encode(new Stack())); + } + + [Fact] + public void Encode_reverses_to_bottom_to_top_wire_order() + { + // Push 1, 2, 3 → top=3. Wire must be [1, 2, 3] in + // bottom-to-top order so the server's java.util.Stack lands + // with 1 at the bottom and 3 on top. .NET Stack iterates + // top→bottom natively (yields 3, 2, 1); the converter reverses + // that. Mirrors clicache CacheableStack::ToData calling + // Linq::Enumerable::Reverse(stack). + var stack = new Stack(); + stack.Push(1); + stack.Push(2); + stack.Push(3); + + Assert.Equal( + new byte[] + { + DSCode.CacheableStack, 0x03, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x01, // bottom + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x02, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x03, // top + }, + SerializationTestHelpers.Encode(stack)); + } + + [Fact] + public void Decode_reads_bottom_to_top_and_pushes_in_wire_order() + { + // Wire [1, 2, 3] (bottom-to-top) → Push(1); Push(2); Push(3) + // → final stack has 1 at bottom, 3 on top. + var wire = new byte[] + { + DSCode.CacheableStack, 0x03, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x01, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x02, + DSCode.CacheableInt32, 0x00, 0x00, 0x00, 0x03, + }; + + var typed = (Stack)SerializationTestHelpers.Decode(wire)!; + Assert.Equal(3, typed.Count); + Assert.Equal(3, typed.Peek()); // top = last-pushed + Assert.Equal(3, typed.Pop()); + Assert.Equal(2, typed.Pop()); + Assert.Equal(1, typed.Pop()); + } + + [Fact] + public void Decode_zero_length_returns_empty_canonical_stack() + { + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableStack, 0x00 }); + var typed = Assert.IsType>(result); + Assert.Empty(typed); + } + + [Fact] + public void RoundTrip_returns_canonical_Stack_object() + { + // Push order A, B, C → top=C. Round trip must preserve that: + // peek = C, pop = C → B → A. + var value = new Stack(); + value.Push(10); + value.Push(20); + value.Push(30); + + var decoded = SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value)); + + var typed = Assert.IsType>(decoded); + Assert.Equal(30, typed.Peek()); + Assert.Equal(30, typed.Pop()); + Assert.Equal(20, typed.Pop()); + Assert.Equal(10, typed.Pop()); + } + + [Fact] + public void RoundTrip_single_element() + { + // N=1 is the degenerate case where reverse is a no-op; check + // the trivial path still works. + var value = new Stack(); + value.Push("only"); + + var decoded = (Stack)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Single(decoded); + Assert.Equal("only", decoded.Peek()); + } + + [Fact] + public void RoundTrip_with_null_element() + { + var value = new Stack(); + value.Push("a"); + value.Push(null); + value.Push("b"); + + var decoded = (Stack)SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + Assert.Equal(3, decoded.Count); + Assert.Equal("b", decoded.Pop()); + Assert.Null(decoded.Pop()); + Assert.Equal("a", decoded.Pop()); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/StringArrayDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/StringArrayDataConverterTests.cs new file mode 100644 index 0000000..0384c4a --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/StringArrayDataConverterTests.cs @@ -0,0 +1,94 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class StringArrayDataConverterTests +{ + [Fact] + public void Encode_empty_array_writes_dscode_and_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableStringArray, 0x00 }, + SerializationTestHelpers.Encode(Array.Empty())); + } + + [Fact] + public void Encode_ascii_element_routes_through_CacheableASCIIString() + { + // {"A"} → length-1 + (DSCode 87 + u16 length 1 + 'A'). + Assert.Equal( + new byte[] + { + DSCode.CacheableStringArray, 0x01, + DSCode.CacheableASCIIString, 0x00, 0x01, 0x41, + }, + SerializationTestHelpers.Encode(new[] { "A" })); + } + + [Fact] + public void Encode_non_ascii_element_routes_through_CacheableString_modUtf8() + { + // {"中"} → length-1 + (DSCode 42 + u16 byte-length 3 + 0xE4 0xB8 0xAD). + // U+4E2D in Java modified UTF-8 is the same 3-byte sequence as + // standard UTF-8 (mod-UTF-8 only diverges for NUL and + // supplementary code points). + Assert.Equal( + new byte[] + { + DSCode.CacheableStringArray, 0x01, + DSCode.CacheableString, 0x00, 0x03, 0xE4, 0xB8, 0xAD, + }, + SerializationTestHelpers.Encode(new[] { "中" })); + } + + [Fact] + public void Encode_null_element_routes_through_NullObj() + { + // {null} → length-1 + DSCode 41. No per-element body. + Assert.Equal( + new byte[] + { + DSCode.CacheableStringArray, 0x01, + DSCode.NullObj, + }, + SerializationTestHelpers.Encode(new string?[] { null })); + } + + [Fact] + public void RoundTrip_empty() + { + var result = SerializationTestHelpers.RoundTrip(Array.Empty()); + Assert.Equal(Array.Empty(), result); + } + + [Fact] + public void RoundTrip_ascii_elements() + { + var value = new[] { "alpha", "beta", "gamma" }; + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_mixed_ascii_and_cjk() + { + // Forces the registry to pick different per-element DSCodes + // (87 for "Hello", 42 for the CJK element). + var value = new[] { "Hello", "中文", "World" }; + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_with_null_elements_preserves_positions() + { + var value = new string?[] { "a", null, "b", null }; + var result = (string?[])SerializationTestHelpers.Decode( + SerializationTestHelpers.Encode(value))!; + + Assert.Equal(4, result.Length); + Assert.Equal("a", result[0]); + Assert.Null(result[1]); + Assert.Equal("b", result[2]); + Assert.Null(result[3]); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/StringDataConverterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/StringDataConverterTests.cs new file mode 100644 index 0000000..f9e493f --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/StringDataConverterTests.cs @@ -0,0 +1,196 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class StringDataConverterTests +{ + // ──────────────────────────────────────────────────────────── + // DSCode 87 — CacheableASCIIString (u16 char-count + ASCII bytes) + // ──────────────────────────────────────────────────────────── + + [Fact] + public void Encode_empty_string_writes_ascii_short_with_zero_length() + { + Assert.Equal( + new byte[] { DSCode.CacheableASCIIString, 0x00, 0x00 }, + SerializationTestHelpers.Encode("")); + } + + [Fact] + public void Encode_single_ascii_char_writes_ascii_short() + { + Assert.Equal( + new byte[] { DSCode.CacheableASCIIString, 0x00, 0x01, 0x41 }, + SerializationTestHelpers.Encode("A")); + } + + [Fact] + public void Encode_pure_ascii_string_writes_ascii_short() + { + Assert.Equal( + new byte[] + { + DSCode.CacheableASCIIString, + 0x00, 0x05, + (byte)'H', (byte)'e', (byte)'l', (byte)'l', (byte)'o', + }, + SerializationTestHelpers.Encode("Hello")); + } + + // ──────────────────────────────────────────────────────────── + // DSCode 42 — CacheableString (u16 byte-count + modified UTF-8) + // ──────────────────────────────────────────────────────────── + + [Fact] + public void Encode_non_ascii_picks_modified_utf8_short() + { + // 'é' = U+00E9 → 2-byte modified UTF-8: 0xC3 0xA9. + Assert.Equal( + new byte[] { DSCode.CacheableString, 0x00, 0x02, 0xC3, 0xA9 }, + SerializationTestHelpers.Encode("é")); + } + + [Fact] + public void Encode_cjk_char_uses_three_byte_modified_utf8() + { + // '中' = U+4E2D → 3-byte modified UTF-8: 0xE4 0xB8 0xAD. + Assert.Equal( + new byte[] { DSCode.CacheableString, 0x00, 0x03, 0xE4, 0xB8, 0xAD }, + SerializationTestHelpers.Encode("中")); + } + + [Fact] + public void Encode_embedded_nul_uses_two_byte_modified_utf8() + { + // \0 in modified UTF-8 is 0xC0 0x80 (NOT 0x00) — the + // distinguishing feature from standard UTF-8. + Assert.Equal( + new byte[] { DSCode.CacheableString, 0x00, 0x02, 0xC0, 0x80 }, + SerializationTestHelpers.Encode("\0")); + } + + [Fact] + public void Encode_mixed_ascii_and_non_ascii_uses_modified_utf8() + { + // 'A' = 0x41 (1 byte), 'é' = 0xC3 0xA9 (2 bytes). Total 3 bytes. + Assert.Equal( + new byte[] { DSCode.CacheableString, 0x00, 0x03, 0x41, 0xC3, 0xA9 }, + SerializationTestHelpers.Encode("Aé")); + } + + // ──────────────────────────────────────────────────────────── + // DSCode 88 — CacheableASCIIStringHuge (u32 char-count + ASCII bytes) + // ──────────────────────────────────────────────────────────── + + [Fact] + public void Encode_ascii_above_short_threshold_picks_ascii_huge() + { + // 65536 chars = boundary just above u16 max. Picked huge form. + var value = new string('x', 65536); + var encoded = SerializationTestHelpers.Encode(value); + + // First 5 bytes: DSCode 88 + u32 length 65536. + Assert.Equal(DSCode.CacheableASCIIStringHuge, encoded[0]); + Assert.Equal(new byte[] { 0x00, 0x01, 0x00, 0x00 }, encoded[1..5]); + Assert.Equal(1 + 4 + 65536, encoded.Length); + Assert.Equal((byte)'x', encoded[5]); + Assert.Equal((byte)'x', encoded[^1]); + } + + // ──────────────────────────────────────────────────────────── + // DSCode 89 — CacheableStringHuge (u32 char-count + UTF-16 BE) + // ──────────────────────────────────────────────────────────── + + [Fact] + public void Encode_non_ascii_above_utf8_threshold_picks_utf16_huge() + { + // 35000 × 'é' = 70000 modified-UTF-8 bytes > 65535 → picks + // DSCode 89 (UTF-16 BE). NOT 2-byte UTF-8 huge — cppcache + // switches encoding at this point. + var value = new string('é', 35000); + var encoded = SerializationTestHelpers.Encode(value); + + Assert.Equal(DSCode.CacheableStringHuge, encoded[0]); + // u32 length is char count (35000 = 0x88B8), not byte count. + Assert.Equal(new byte[] { 0x00, 0x00, 0x88, 0xB8 }, encoded[1..5]); + Assert.Equal(1 + 4 + 35000 * 2, encoded.Length); + // Each 'é' = U+00E9 written as UTF-16 BE: 0x00 0xE9. + Assert.Equal((byte)0x00, encoded[5]); + Assert.Equal((byte)0xE9, encoded[6]); + } + + // ──────────────────────────────────────────────────────────── + // DSCode 69 — CacheableNullString (decode-only) + // ──────────────────────────────────────────────────────────── + + [Fact] + public void Decode_null_string_sentinel_returns_null() + { + // cppcache writes 69 for null in typed-string slots; we + // intercept null with 41 (NullObj) on write but tolerate 69 + // on read for server-compat. Payload is the bare DSCode byte. + var result = SerializationTestHelpers.Decode( + new byte[] { DSCode.CacheableNullString }); + Assert.Null(result); + } + + // ──────────────────────────────────────────────────────────── + // Round-trips + // ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData("")] + [InlineData("A")] + [InlineData("Hello, World")] + [InlineData("é")] + [InlineData("中文測試")] + [InlineData("\0")] + [InlineData("\0middle\0end")] + [InlineData("mixed Aé中")] + [InlineData("￿")] // max BMP char + public void RoundTrip(string value) => + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + + [Fact] + public void RoundTrip_unpaired_high_surrogate() + { + // 0xD800..0xDBFF is the high-surrogate range. Each surrogate + // half encodes as a 3-byte modified-UTF-8 sequence and must + // round-trip intact. + Assert.Equal("\uD800", SerializationTestHelpers.RoundTrip("\uD800")); + } + + [Fact] + public void RoundTrip_unpaired_low_surrogate() + { + Assert.Equal("\uDFFF", SerializationTestHelpers.RoundTrip("\uDFFF")); + } + + [Fact] + public void RoundTrip_huge_ascii() + { + var value = new string('a', 70000); + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_huge_utf16() + { + // Force the UTF-16 huge path: 35000 × non-ASCII = 70000 mod + // UTF-8 bytes which exceeds u16, so encoding switches to UTF-16. + var value = new string('中', 35000); + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + } + + [Fact] + public void RoundTrip_surrogate_pair_via_modified_utf8() + { + // U+1F600 (😀) encoded in .NET as two UTF-16 code units + // (D83D, DE00). Modified UTF-8 encodes each surrogate half + // independently as 3 bytes → 6 bytes total, round-trips intact. + var value = "😀"; + Assert.Equal(value, SerializationTestHelpers.RoundTrip(value)); + Assert.Equal(2, value.Length); // sanity: two UTF-16 code units + } +} diff --git a/tests/Geode.Client.Tests/Protocol/Serialization/TypedResultAdapterTests.cs b/tests/Geode.Client.Tests/Protocol/Serialization/TypedResultAdapterTests.cs new file mode 100644 index 0000000..7d4ecec --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/Serialization/TypedResultAdapterTests.cs @@ -0,0 +1,340 @@ +using Geode.Client.Protocol.Serialization; +using Xunit; + +namespace Geode.Client.Tests.Protocol.Serialization; + +public class TypedResultAdapterTests +{ + private readonly TypedResultAdapter _adapter = new(); + + // ── Null handling ────────────────────────────────────────── + + [Fact] + public void Convert_null_returns_null_for_reference_target() + { + Assert.Null(_adapter.Convert(null)); + Assert.Null(_adapter.Convert>(null)); + Assert.Null(_adapter.Convert(null)); + } + + [Fact] + public void Convert_null_returns_default_for_value_target() + { + // Matches IDictionary.TryGetValue semantics: + // missing → default(TValue). 0 for int, false for bool. + Assert.Equal(0, _adapter.Convert(null)); + Assert.False(_adapter.Convert(null)); + } + + [Fact] + public void Convert_non_generic_null_returns_null() + { + Assert.Null(_adapter.Convert(null, typeof(string))); + Assert.Null(_adapter.Convert(null, typeof(int))); + } + + // ── Early out (IsInstanceOfType) ─────────────────────────── + + [Fact] + public void Convert_scalar_already_target_type_passes_through() + { + Assert.Equal(42, _adapter.Convert(42)); + Assert.Equal("hello", _adapter.Convert("hello")); + Assert.True(_adapter.Convert(true)); + } + + [Fact] + public void Convert_primitive_array_already_target_type_passes_through() + { + var arr = new[] { 1, 2, 3 }; + Assert.Same(arr, _adapter.Convert(arr)); + } + + [Fact] + public void Convert_concrete_list_already_assignable_passes_through() + { + // List IS-A IList → IsInstanceOfType early-out, no + // allocation, same reference returned. + var list = new List { 1, 2, 3 }; + Assert.Same(list, _adapter.Convert>(list)); + Assert.Same(list, _adapter.Convert>(list)); + Assert.Same(list, _adapter.Convert>(list)); + } + + // ── IList family ──────────────────────────────────────── + + [Fact] + public void Convert_canonical_to_IList_int() + { + var raw = new List { 1, 2, 3 }; + var result = _adapter.Convert>(raw); + Assert.IsType>(result); + Assert.Equal(new[] { 1, 2, 3 }, result); + } + + [Fact] + public void Convert_canonical_to_List_string() + { + var raw = new List { "a", "b" }; + var result = _adapter.Convert>(raw); + Assert.Equal(new[] { "a", "b" }, result); + } + + [Theory] + [InlineData(typeof(IList))] + [InlineData(typeof(List))] + [InlineData(typeof(IEnumerable))] + [InlineData(typeof(ICollection))] + [InlineData(typeof(IReadOnlyList))] + [InlineData(typeof(IReadOnlyCollection))] + public void Convert_materialises_List_T_for_every_supported_list_shape(Type targetType) + { + // All six target shapes converge on List as the materialised + // form — assignment compatibility lines up for each. + var raw = new List { 1, 2, 3 }; + var result = _adapter.Convert(raw, targetType); + Assert.NotNull(result); + Assert.IsType>(result); + Assert.True(targetType.IsInstanceOfType(result)); + } + + // ── Nested ───────────────────────────────────────────────── + + [Fact] + public void Convert_nested_IList_of_IList_string() + { + var raw = new List + { + new List { "a", "b" }, + new List { "c" }, + }; + var result = _adapter.Convert>>(raw); + Assert.NotNull(result); + Assert.Equal(2, result!.Count); + Assert.Equal(new[] { "a", "b" }, result[0]); + Assert.Equal(new[] { "c" }, result[1]); + } + + [Fact] + public void Convert_three_level_nested_IList() + { + var raw = new List + { + new List + { + new List { 1 }, + new List { 2, 3 }, + }, + }; + var result = _adapter.Convert>>>(raw); + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(2, result![0].Count); + Assert.Equal(new[] { 1 }, result[0][0]); + Assert.Equal(new[] { 2, 3 }, result[0][1]); + } + + [Fact] + public void Convert_nested_null_element_stays_null() + { + var raw = new List + { + new List { "a" }, + null, + }; + var result = _adapter.Convert>>(raw); + Assert.NotNull(result); + Assert.Equal(2, result!.Count); + Assert.Equal(new[] { "a" }, result[0]); + Assert.Null(result[1]); + } + + // ── ISet / HashSet ─────────────────────────────────── + + [Fact] + public void Convert_canonical_to_HashSet_int() + { + var raw = new HashSet { 1, 2, 3 }; + var result = _adapter.Convert>(raw); + Assert.NotNull(result); + Assert.Equal(new HashSet { 1, 2, 3 }, result); + } + + [Theory] + [InlineData(typeof(ISet))] + [InlineData(typeof(HashSet))] + [InlineData(typeof(IReadOnlySet))] + public void Convert_materialises_HashSet_T_for_every_supported_set_shape(Type targetType) + { + var raw = new HashSet { 1, 2, 3 }; + var result = _adapter.Convert(raw, targetType); + Assert.NotNull(result); + Assert.IsType>(result); + Assert.True(targetType.IsInstanceOfType(result)); + } + + [Fact] + public void Convert_canonical_set_with_null_to_ISet_nullable_string() + { + // ISet with null member — canonical HashSet + // already permits null, materialised as HashSet. + var raw = new HashSet { "a", null, "b" }; + var result = _adapter.Convert>(raw); + Assert.NotNull(result); + Assert.Equal(3, result!.Count); + Assert.Contains("a", result); + Assert.Contains(null, result); + Assert.Contains("b", result); + } + + // ── IDictionary / Dictionary ───────────────────── + + [Fact] + public void Convert_canonical_to_Dictionary_int_string() + { + var raw = new Dictionary + { + [1] = "a", + [2] = "b", + }; + var result = _adapter.Convert>(raw); + Assert.NotNull(result); + Assert.Equal(2, result!.Count); + Assert.Equal("a", result[1]); + Assert.Equal("b", result[2]); + } + + [Theory] + [InlineData(typeof(IDictionary))] + [InlineData(typeof(Dictionary))] + [InlineData(typeof(IReadOnlyDictionary))] + public void Convert_materialises_Dictionary_KV_for_every_supported_map_shape(Type targetType) + { + var raw = new Dictionary { [1] = "a" }; + var result = _adapter.Convert(raw, targetType); + Assert.NotNull(result); + Assert.IsType>(result); + Assert.True(targetType.IsInstanceOfType(result)); + } + + [Fact] + public void Convert_dictionary_with_nested_value_recurses() + { + // Nested values use the value-side conversion path + // independently — IDictionary> materialises a + // Dictionary> with each value converted. + var raw = new Dictionary + { + [1] = new List { 10, 20 }, + [2] = new List { 30 }, + }; + var result = _adapter.Convert>>(raw); + Assert.NotNull(result); + Assert.Equal(new[] { 10, 20 }, result![1]); + Assert.Equal(new[] { 30 }, result[2]); + } + + // ── LinkedList ────────────────────────────────────────── + + [Fact] + public void Convert_canonical_to_LinkedList_int_preserves_head_to_tail() + { + var raw = new LinkedList(); + raw.AddLast(1); + raw.AddLast(2); + raw.AddLast(3); + + var result = _adapter.Convert>(raw); + Assert.NotNull(result); + Assert.Equal(new[] { 1, 2, 3 }, result); + } + + [Fact] + public void Convert_canonical_List_object_to_LinkedList_int() + { + // Adapter doesn't care whether source is canonical-shaped + // (LinkedList) or any other IEnumerable — accepts a + // List source too and materialises LinkedList. + var raw = new List { 1, 2, 3 }; + var result = _adapter.Convert>(raw); + Assert.NotNull(result); + Assert.Equal(new[] { 1, 2, 3 }, result); + } + + // ── Stack ─────────────────────────────────────────────── + + [Fact] + public void Convert_canonical_Stack_object_to_Stack_int_preserves_push_order() + { + // Canonical Stack is constructed by pushing in wire + // order (bottom→top), so foreach yields top→bottom. Adapter + // must reverse before constructing typed Stack, otherwise + // the typed stack ends up inverted. + var raw = new Stack(); + raw.Push(10); // bottom + raw.Push(20); + raw.Push(30); // top + + var result = _adapter.Convert>(raw); + Assert.NotNull(result); + Assert.Equal(30, result!.Peek()); + Assert.Equal(30, result.Pop()); + Assert.Equal(20, result.Pop()); + Assert.Equal(10, result.Pop()); + } + + // ── Arrays ───────────────────────────────────────────────── + + [Fact] + public void Convert_canonical_to_int_array() + { + var raw = new List { 1, 2, 3 }; + var result = _adapter.Convert(raw); + Assert.Equal(new[] { 1, 2, 3 }, result); + } + + [Fact] + public void Convert_canonical_to_string_array() + { + var raw = new List { "a", "b" }; + var result = _adapter.Convert(raw); + Assert.Equal(new[] { "a", "b" }, result); + } + + [Fact] + public void Convert_empty_canonical_to_int_array() + { + var raw = new List(); + var result = _adapter.Convert(raw); + Assert.NotNull(result); + Assert.Empty(result!); + } + + // ── Failure modes ────────────────────────────────────────── + + [Fact] + public void Convert_unknown_generic_target_throws() + { + // SortedDictionary<,> is a known unknown — adapter has + // Dictionary<,> / IDictionary<,> branches but doesn't cover + // the sorted variants (follow-up). + var raw = new List { 1, 2 }; + Assert.Throws( + () => _adapter.Convert>(raw)); + } + + [Fact] + public void Convert_scalar_to_list_target_throws() + { + // raw is not enumerable — can't be materialised as a list. + Assert.Throws( + () => _adapter.Convert>(42)); + } + + [Fact] + public void Convert_scalar_to_array_target_throws() + { + Assert.Throws( + () => _adapter.Convert("not an enumerable")); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/TcrMessageBuilderTests.cs b/tests/Geode.Client.Tests/Protocol/TcrMessageBuilderTests.cs new file mode 100644 index 0000000..af5a100 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/TcrMessageBuilderTests.cs @@ -0,0 +1,67 @@ +using Geode.Client.Protocol; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Geode.Client.Tests.Protocol; + +public class TcrMessageBuilderTests +{ + private static ServiceProvider BuildSp() => + new ServiceCollection().BuildServiceProvider(); + + [Fact] + public void Create_ReturnsBuilder() + { + using var sp = BuildSp(); + + var builder = TcrMessageBuilder.Create(sp, MessageType.Ping); + + Assert.NotNull(builder); + } + + [Fact] + public async Task BuildAsync_NoParts_ReturnsMessageWithEmptyParts() + { + using var sp = BuildSp(); + var builder = TcrMessageBuilder.Create(sp, MessageType.Ping); + + var message = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Empty(message.Parts); + } + + [Fact] + public async Task BuildAsync_PreservesMessageType() + { + using var sp = BuildSp(); + var builder = TcrMessageBuilder.Create(sp, MessageType.Ping); + + var message = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(MessageType.Ping, message.MessageType); + } + + [Fact] + public async Task BuildAsync_DefaultTransactionId_IsMinusOne() + { + // -1 sentinel for "no Geode transaction" — mirrors cppcache + // TcrMessage::writeHeader (m_txId = -1 when no TxState). + using var sp = BuildSp(); + var builder = TcrMessageBuilder.Create(sp, MessageType.Ping); + + var message = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(-1, message.TransactionId); + } + + [Fact] + public async Task BuildAsync_DefaultEarlyAck_IsZero() + { + using var sp = BuildSp(); + var builder = TcrMessageBuilder.Create(sp, MessageType.Ping); + + var message = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal((byte)0, message.EarlyAck); + } +} diff --git a/tests/Geode.Client.Tests/Protocol/TcrPartBuilderTests.cs b/tests/Geode.Client.Tests/Protocol/TcrPartBuilderTests.cs new file mode 100644 index 0000000..f427f20 --- /dev/null +++ b/tests/Geode.Client.Tests/Protocol/TcrPartBuilderTests.cs @@ -0,0 +1,102 @@ +using Geode.Client.Protocol; +using Xunit; + +namespace Geode.Client.Tests.Protocol; + +public class TcrPartBuilderTests +{ + [Fact] + public async Task RawBytes_BuildAsync_ReturnsPartWithIsObjectZero() + { + var builder = TcrPartBuilder.RawBytes(new byte[] { 0xAB, 0xCD }); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(0, part.IsObject); + } + + [Fact] + public async Task RawBytes_BuildAsync_PreservesPayload() + { + var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + var builder = TcrPartBuilder.RawBytes(payload); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(payload, part.Payload.ToArray()); + } + + [Fact] + public async Task RawBytes_EmptyPayload_IsAllowed() + { + var builder = TcrPartBuilder.RawBytes(ReadOnlyMemory.Empty); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(0, part.IsObject); + Assert.True(part.Payload.IsEmpty); + } + + [Fact] + public async Task LambdaCtor_BuildAsync_InvokesFunc() + { + var invoked = 0; + var builder = new TcrPartBuilder(_ => + { + invoked++; + return ValueTask.FromResult(new TcrPart(1, new byte[] { 0x42 })); + }); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, invoked); + Assert.Equal(1, part.IsObject); + Assert.Equal(new byte[] { 0x42 }, part.Payload.ToArray()); + } + + [Fact] + public async Task BuildAsync_PassesCancellationTokenThrough() + { + using var cts = new CancellationTokenSource(); + var observed = CancellationToken.None; + var builder = new TcrPartBuilder(ct => + { + observed = ct; + return ValueTask.FromResult(new TcrPart(0, ReadOnlyMemory.Empty)); + }); + + await builder.BuildAsync(cts.Token); + + Assert.Equal(cts.Token, observed); + } + + [Fact] + public async Task KeepAlive_True_EmitsSingleOneByte() + { + var builder = TcrPartBuilder.KeepAlive(true); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(new byte[] { 0x01 }, part.Payload.ToArray()); + } + + [Fact] + public async Task KeepAlive_False_EmitsSingleZeroByte() + { + var builder = TcrPartBuilder.KeepAlive(false); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(new byte[] { 0x00 }, part.Payload.ToArray()); + } + + [Fact] + public async Task KeepAlive_IsObjectZero() + { + var builder = TcrPartBuilder.KeepAlive(true); + + var part = await builder.BuildAsync(TestContext.Current.CancellationToken); + + Assert.Equal(0, part.IsObject); + } +} diff --git a/tests/Geode.Client.Tests/RegionFactoryCreateAsyncTests.cs b/tests/Geode.Client.Tests/RegionFactoryCreateAsyncTests.cs new file mode 100644 index 0000000..c3fa8ca --- /dev/null +++ b/tests/Geode.Client.Tests/RegionFactoryCreateAsyncTests.cs @@ -0,0 +1,216 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests; + +/// +/// Covers +/// orchestration (name validation, pool resolution, registration, typed +/// wrap). Mirrors cppcache RegionFactory::create +/// (cppcache/src/RegionFactory.cpp:43-58) + CacheImpl::createRegion +/// (cppcache/src/CacheImpl.cpp:365-464). +/// +public class RegionFactoryCreateAsyncTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + /// Build a cache with a single pool registered ("p"), enough to satisfy non-Local shortcuts. + private static async Task BuildCacheWithPoolAsync( + ServiceProvider sp, CancellationToken ct, string poolName = "p") + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + await cache.PoolManager.CreateFactory() + .AddServer("h", 40404) + .BuildAsync(poolName, ct); + return cache; + } + + // ── Happy path ──────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_ReturnsRegionWithExpectedNameAndFullPath() + { + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync("orders", ct); + + Assert.Equal("orders", region.Name); + Assert.Equal("/orders", region.FullPath); + } + + [Fact] + public async Task CreateAsync_RegistersRegionOnCache() + { + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + + await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync("orders", ct); + + Assert.NotNull(cache.GetRegion("orders")); + } + + [Fact] + public async Task CreateAsync_ReturnsTypedView_AssignableToBothIRegionVariants() + { + // Result must satisfy IRegion; implementation wraps + // a non-generic IRegion in RegionView so both views work. + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync("orders", ct); + + Assert.IsAssignableFrom>(region); + Assert.IsAssignableFrom(region); + } + + // ── Pool resolution ────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_NoExplicitPoolName_AutoFillsFromDefaultPool() + { + // cppcache RegionFactory.cpp:46-54 — non-LOCAL + empty PoolName + // → resolve to default pool name; write back into attrs. + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct, poolName: "myDefaultPool"); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync("r", ct); + + Assert.Equal("myDefaultPool", region.PoolName); + } + + [Fact] + public async Task CreateAsync_ExplicitPoolName_FlowsThrough() + { + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct, poolName: "p"); + + var region = await cache.CreateRegionFactory(RegionShortcut.Proxy) + .SetPoolName("p") + .CreateAsync("r", ct); + + Assert.Equal("p", region.PoolName); + } + + // ── Name validation ───────────────────────────────────────── + + [Fact] + public async Task CreateAsync_EmptyName_ThrowsArgumentException() + { + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + var f = cache.CreateRegionFactory(RegionShortcut.Proxy); + + await Assert.ThrowsAsync(() => f.CreateAsync("", ct)); + } + + [Fact] + public async Task CreateAsync_NameWithSlash_ThrowsArgumentException() + { + // cppcache CacheImpl.cpp:385-388 — "Malformed name string, + // contains region path seperator '/'". + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + var f = cache.CreateRegionFactory(RegionShortcut.Proxy); + + await Assert.ThrowsAsync( + () => f.CreateAsync("a/b", ct)); + } + + // ── Shortcut gating ───────────────────────────────────────── + + [Fact] + public async Task CreateAsync_LocalShortcut_ThrowsNotImplementedException() + { + // Local needs a server-less Region impl; Phase 2+. + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + var f = cache.CreateRegionFactory(RegionShortcut.Local); + + await Assert.ThrowsAsync( + () => f.CreateAsync("r", ct)); + } + + // ── Pool unavailable ──────────────────────────────────────── + + [Fact] + public async Task CreateAsync_NoPoolRegistered_NonLocalShortcut_ThrowsInvalidOperationException() + { + // Non-Local shortcut + no PoolName + no DefaultPool → cannot resolve. + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + var f = cache.CreateRegionFactory(RegionShortcut.Proxy); + + await Assert.ThrowsAsync( + () => f.CreateAsync("r", ct)); + } + + [Fact] + public async Task CreateAsync_UnknownExplicitPoolName_ThrowsInvalidOperationException() + { + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct, poolName: "p"); + var f = cache.CreateRegionFactory(RegionShortcut.Proxy).SetPoolName("doesNotExist"); + + await Assert.ThrowsAsync( + () => f.CreateAsync("r", ct)); + } + + // ── Duplicate registration ─────────────────────────────────── + + [Fact] + public async Task CreateAsync_DuplicateName_ThrowsRegionExistsException() + { + // cppcache CacheImpl.cpp:395-398 — second create with same name + // throws RegionExistsException. + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + + await cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync("orders", ct); + + await Assert.ThrowsAsync( + () => cache.CreateRegionFactory(RegionShortcut.Proxy) + .CreateAsync("orders", ct)); + } + + // ── Cache lifecycle ───────────────────────────────────────── + + [Fact] + public async Task CreateAsync_AfterCacheClose_ThrowsObjectDisposedException() + { + await using var sp = BuildSp(); + var ct = TestContext.Current.CancellationToken; + var cache = await BuildCacheWithPoolAsync(sp, ct); + var f = cache.CreateRegionFactory(RegionShortcut.Proxy); + await cache.CloseAsync(ct); + + await Assert.ThrowsAsync( + () => f.CreateAsync("r", ct)); + } +} diff --git a/tests/Geode.Client.Tests/RegionFactoryShortcutPresetTests.cs b/tests/Geode.Client.Tests/RegionFactoryShortcutPresetTests.cs new file mode 100644 index 0000000..aad6254 --- /dev/null +++ b/tests/Geode.Client.Tests/RegionFactoryShortcutPresetTests.cs @@ -0,0 +1,133 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests; + +/// +/// Locks the cppcache RegionFactory::setRegionShortcut() +/// (cppcache/src/RegionFactory.cpp:60-80) preset matrix: ctor +/// applies cachingEnabled + lruEntriesLimit presets per +/// , then user setter calls override. +/// +public class RegionFactoryShortcutPresetTests +{ + // cppcache CacheImpl.hpp:43 + private const int DefaultLruMaximumEntries = 100000; + + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static async Task BuildFactoryAsync( + ServiceProvider sp, CancellationToken ct, RegionShortcut shortcut) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + return cache.CreateRegionFactory(shortcut); + } + + // ── Ctor-time presets per shortcut ──────────────────────────── + + [Fact] + public async Task Proxy_DisablesCaching() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.Proxy); + var attrs = f.SnapshotAttributes(); + + Assert.False(attrs.CachingEnabled); + Assert.Equal(0, attrs.LruEntriesLimit); + } + + [Fact] + public async Task CachingProxy_EnablesCaching_NoLru() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.CachingProxy); + var attrs = f.SnapshotAttributes(); + + Assert.True(attrs.CachingEnabled); + Assert.Equal(0, attrs.LruEntriesLimit); + } + + [Fact] + public async Task CachingProxyEntryLru_EnablesCachingAndSetsLru() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.CachingProxyEntryLru); + var attrs = f.SnapshotAttributes(); + + Assert.True(attrs.CachingEnabled); + Assert.Equal(DefaultLruMaximumEntries, attrs.LruEntriesLimit); + } + + [Fact] + public async Task Local_NoPresetsApplied() + { + // cppcache RegionFactory.cpp:73 — empty case; caching=true (the + // RegionAttributes default) and LRU disabled both carry through. + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.Local); + var attrs = f.SnapshotAttributes(); + + Assert.True(attrs.CachingEnabled); + Assert.Equal(0, attrs.LruEntriesLimit); + } + + [Fact] + public async Task LocalEntryLru_SetsLru_KeepsCachingDefault() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.LocalEntryLru); + var attrs = f.SnapshotAttributes(); + + Assert.True(attrs.CachingEnabled); + Assert.Equal(DefaultLruMaximumEntries, attrs.LruEntriesLimit); + } + + // ── User setter overrides ctor preset ──────────────────────── + + [Fact] + public async Task UserSetCachingEnabled_OverridesProxyPreset() + { + // Precedence test: ctor wrote caching=false for Proxy; user + // setter must be able to override (cppcache: setters mutate + // the same RegionAttributesFactory after the ctor preset, so + // last write wins). + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.Proxy); + + f.SetCachingEnabled(true); + + Assert.True(f.SnapshotAttributes().CachingEnabled); + } + + [Fact] + public async Task UserSetLruEntriesLimit_OverridesEntryLruPreset() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.CachingProxyEntryLru); + + f.SetLruEntriesLimit(42); + + Assert.Equal(42, f.SnapshotAttributes().LruEntriesLimit); + } + + // ── Shortcut accessor ──────────────────────────────────────── + + [Fact] + public async Task Shortcut_ReflectsCtorArgument() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken, RegionShortcut.LocalEntryLru); + + Assert.Equal(RegionShortcut.LocalEntryLru, f.Shortcut); + } +} diff --git a/tests/Geode.Client.Tests/RegionFactoryTests.cs b/tests/Geode.Client.Tests/RegionFactoryTests.cs new file mode 100644 index 0000000..9f13511 --- /dev/null +++ b/tests/Geode.Client.Tests/RegionFactoryTests.cs @@ -0,0 +1,45 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests; + +public class RegionFactoryTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static async Task BuildFactoryAsync( + ServiceProvider sp, CancellationToken ct, RegionShortcut shortcut = RegionShortcut.Proxy) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + return cache.CreateRegionFactory(shortcut); + } + + [Fact] + public async Task Setters_ReturnSameFactoryInstance() + { + await using var sp = BuildSp(); + var f = await BuildFactoryAsync(sp, TestContext.Current.CancellationToken); + + Assert.Same(f, f.SetPoolName("p")); + Assert.Same(f, f.SetInitialCapacity(64)); + Assert.Same(f, f.SetLoadFactor(0.5f)); + Assert.Same(f, f.SetConcurrencyLevel(8)); + Assert.Same(f, f.SetLruEntriesLimit(100)); + Assert.Same(f, f.SetCachingEnabled(false)); + Assert.Same(f, f.SetCloningEnabled(true)); + Assert.Same(f, f.SetConcurrencyChecksEnabled(false)); + } + + // CreateAsync orchestration tests live in RegionFactoryCreateAsyncTests.cs; + // this file keeps the cheap surface-level facts (setter fluency). +} diff --git a/tests/Geode.Client.Tests/RegionShortcutTests.cs b/tests/Geode.Client.Tests/RegionShortcutTests.cs new file mode 100644 index 0000000..d293274 --- /dev/null +++ b/tests/Geode.Client.Tests/RegionShortcutTests.cs @@ -0,0 +1,29 @@ +using Geode.Client; +using Xunit; + +namespace Geode.Client.Tests; + +/// +/// Parity lock for cppcache RegionShortcut +/// (cppcache/include/geode/RegionShortcut.hpp:44-72): five values +/// in the documented order. +/// +public class RegionShortcutTests +{ + [Fact] + public void Has_Exactly_Five_Members() + { + var names = Enum.GetNames(); + Assert.Equal(5, names.Length); + } + + [Fact] + public void Members_Match_Cppcache() + { + Assert.True(Enum.IsDefined(RegionShortcut.Proxy)); + Assert.True(Enum.IsDefined(RegionShortcut.CachingProxy)); + Assert.True(Enum.IsDefined(RegionShortcut.CachingProxyEntryLru)); + Assert.True(Enum.IsDefined(RegionShortcut.Local)); + Assert.True(Enum.IsDefined(RegionShortcut.LocalEntryLru)); + } +} diff --git a/tests/Geode.Client.Tests/Services/GeodeCacheFactoryCreationTests.cs b/tests/Geode.Client.Tests/Services/GeodeCacheFactoryCreationTests.cs new file mode 100644 index 0000000..12c2507 --- /dev/null +++ b/tests/Geode.Client.Tests/Services/GeodeCacheFactoryCreationTests.cs @@ -0,0 +1,40 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Services; + +public class GeodeCacheFactoryCreationTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task AddGeodeFactory_Registers_IGeodeCacheFactory() + { + await using var sp = BuildSp(); + + var factory = sp.GetService(); + + Assert.NotNull(factory); + } + + [Fact] + public async Task IGeodeCacheFactory_Resolves_AsSingleton() + { + await using var sp = BuildSp(); + + var first = sp.GetRequiredService(); + var second = sp.GetRequiredService(); + + Assert.Same(first, second); + } +} diff --git a/tests/Geode.Client.Tests/Services/GeodeCacheFactoryOptionsBridgeTests.cs b/tests/Geode.Client.Tests/Services/GeodeCacheFactoryOptionsBridgeTests.cs new file mode 100644 index 0000000..a5a26c9 --- /dev/null +++ b/tests/Geode.Client.Tests/Services/GeodeCacheFactoryOptionsBridgeTests.cs @@ -0,0 +1,320 @@ +using Geode.Client; +using Geode.Client.Internal; +using Geode.Client.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Services; + +/// +/// Locks the GeodeClientOptions → SystemProperties bridge: +/// values set inside +/// callback land on cache.CacheProperties at build time and stay +/// snapshot-stable afterwards. +/// +public class GeodeCacheFactoryOptionsBridgeTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + /// Resolve internal CacheProperties via the InternalsVisibleTo seam. + private static SystemProperties Props(IGeodeCache cache) => ((GeodeCache)cache).CacheProperties; + + // ── No-configure paths use defaults ─────────────────────────── + + [Fact] + public async Task TwoArgOverload_UsesDefaults() + { + await using var sp = BuildSp(); + var cache = await sp.GetRequiredService() + .CreateAsync("c", TestContext.Current.CancellationToken); + + Assert.Equal(string.Empty, Props(cache).Name); + Assert.Equal(1_000_000, Props(cache).MaxArrayLength); + } + + [Fact] + public async Task ThreeArgOverload_NullConfigure_UsesDefaults() + { + await using var sp = BuildSp(); + var cache = await sp.GetRequiredService() + .CreateAsync("c", configure: null, TestContext.Current.CancellationToken); + + Assert.Equal(string.Empty, Props(cache).Name); + Assert.Equal(1_000_000, Props(cache).MaxArrayLength); + } + + // ── Callback invocation contract ────────────────────────────── + + [Fact] + public async Task Configure_Invoked_Once() + { + var calls = 0; + await using var sp = BuildSp(); + await sp.GetRequiredService() + .CreateAsync("c", (_, _) => calls++, TestContext.Current.CancellationToken); + + Assert.Equal(1, calls); + } + + [Fact] + public async Task Configure_ReceivesFactoryServiceProvider() + { + IServiceProvider? captured = null; + await using var sp = BuildSp(); + await sp.GetRequiredService() + .CreateAsync("c", (_, csp) => captured = csp, TestContext.Current.CancellationToken); + + Assert.NotNull(captured); + // Verify caller can pull real services through the captured SP + // (the whole point of the IServiceProvider parameter). + Assert.NotNull(captured!.GetRequiredService()); + } + + // ── Top-level mapping ───────────────────────────────────────── + + [Fact] + public async Task Mapping_Name() + { + var cache = await CreateWith(o => o.Name = "my-client"); + Assert.Equal("my-client", Props(cache).Name); + } + + [Fact] + public async Task Mapping_ThreadPoolSize() + { + var cache = await CreateWith(o => o.ThreadPoolSize = 42); + Assert.Equal(42u, Props(cache).ThreadPoolSize); + } + + // ── Subscription ────────────────────────────────────────────── + + [Fact] + public async Task Mapping_Subscription_DurableClientId() + { + var cache = await CreateWith(o => o.Subscription.DurableClientId = "client-42"); + Assert.Equal("client-42", Props(cache).DurableClientId); + } + + [Fact] + public async Task Mapping_Subscription_DurableTimeout() + { + var cache = await CreateWith(o => o.Subscription.DurableTimeout = TimeSpan.FromMinutes(7)); + Assert.Equal(TimeSpan.FromMinutes(7), Props(cache).DurableTimeout); + } + + [Fact] + public async Task Mapping_Subscription_AutoReadyForEvents() + { + var cache = await CreateWith(o => o.Subscription.AutoReadyForEvents = false); + Assert.False(Props(cache).AutoReadyForEvents); + } + + [Fact] + public async Task Mapping_Subscription_RedundancyMonitorInterval() + { + var cache = await CreateWith(o => o.Subscription.RedundancyMonitorInterval = TimeSpan.FromSeconds(33)); + Assert.Equal(TimeSpan.FromSeconds(33), Props(cache).RedundancyMonitorInterval); + } + + [Fact] + public async Task Mapping_Subscription_NotifyAckInterval() + { + var cache = await CreateWith(o => o.Subscription.NotifyAckInterval = TimeSpan.FromSeconds(2)); + Assert.Equal(TimeSpan.FromSeconds(2), Props(cache).NotifyAckInterval); + } + + [Fact] + public async Task Mapping_Subscription_NotifyDupCheckLife() + { + var cache = await CreateWith(o => o.Subscription.NotifyDupCheckLife = TimeSpan.FromSeconds(123)); + Assert.Equal(TimeSpan.FromSeconds(123), Props(cache).NotifyDupCheckLife); + } + + // ── Security ────────────────────────────────────────────────── + + [Fact] + public async Task Mapping_Security_ClientDhAlgo() + { + var cache = await CreateWith(o => o.Security.ClientDhAlgo = "DH:1024"); + Assert.Equal("DH:1024", Props(cache).SecurityClientDhAlgo); + } + + [Fact] + public async Task Mapping_Security_ClientKsPath() + { + var cache = await CreateWith(o => o.Security.ClientKsPath = "/path/to/ks.jks"); + Assert.Equal("/path/to/ks.jks", Props(cache).SecurityClientKsPath); + } + + [Fact] + public async Task Mapping_Security_Properties() + { + var cache = await CreateWith(o => o.Security.Properties["security-username"] = "admin"); + Assert.Equal("admin", Props(cache).SecurityProperties["security-username"]); + } + + // ── Heap ────────────────────────────────────────────────────── + + [Fact] + public async Task Mapping_Heap_LRULimit() + { + // ulong public ↔ long internal (cast at bridge — Phase 1.x wire is i64). + var cache = await CreateWith(o => o.Heap.LRULimit = 1_000_000UL); + Assert.Equal(1_000_000L, Props(cache).HeapLRULimit); + } + + [Fact] + public async Task Mapping_Heap_LRUDelta() + { + var cache = await CreateWith(o => o.Heap.LRUDelta = 25); + Assert.Equal(25, Props(cache).HeapLRUDelta); + } + + // ── Tls ─────────────────────────────────────────────────────── + + [Fact] + public async Task Mapping_Tls_Enabled() + { + var cache = await CreateWith(o => o.Tls.Enabled = true); + Assert.True(Props(cache).SslEnabled); + } + + // ── Pool ────────────────────────────────────────────────────── + + [Fact] + public async Task Mapping_Pool_ConnectionPoolSize() + { + var cache = await CreateWith(o => o.Pool.ConnectionPoolSize = 12); + Assert.Equal(12u, Props(cache).ConnectionPoolSize); + } + + [Fact] + public async Task Mapping_Pool_ConnectTimeout() + { + var cache = await CreateWith(o => o.Pool.ConnectTimeout = TimeSpan.FromSeconds(15)); + Assert.Equal(TimeSpan.FromSeconds(15), Props(cache).ConnectTimeout); + } + + [Fact] + public async Task Mapping_Pool_ConnectWaitTimeout() + { + var cache = await CreateWith(o => o.Pool.ConnectWaitTimeout = TimeSpan.FromSeconds(8)); + Assert.Equal(TimeSpan.FromSeconds(8), Props(cache).ConnectWaitTimeout); + } + + [Fact] + public async Task Mapping_Pool_MaxSocketBufferSize() + { + var cache = await CreateWith(o => o.Pool.MaxSocketBufferSize = 128 * 1024); + Assert.Equal(128 * 1024, Props(cache).MaxSocketBufferSize); + } + + [Fact] + public async Task Mapping_Pool_PingInterval() + { + var cache = await CreateWith(o => o.Pool.PingInterval = TimeSpan.FromSeconds(7)); + Assert.Equal(TimeSpan.FromSeconds(7), Props(cache).PingInterval); + } + + [Fact] + public async Task Mapping_Pool_BucketWaitTimeout() + { + var cache = await CreateWith(o => o.Pool.BucketWaitTimeout = TimeSpan.FromMilliseconds(250)); + Assert.Equal(TimeSpan.FromMilliseconds(250), Props(cache).BucketWaitTimeout); + } + + [Fact] + public async Task Mapping_Pool_ShuffleEndpoints_InvertsToDisableShufflingEndpoint() + { + // ShuffleEndpoints=false → DisableShufflingEndpoint=true (cppcache parity). + var cache = await CreateWith(o => o.Pool.ShuffleEndpoints = false); + Assert.True(Props(cache).DisableShufflingEndpoint); + } + + [Fact] + public async Task Mapping_Pool_ShuffleEndpoints_DefaultTrue_LeavesDisableFalse() + { + var cache = await CreateWith(_ => { /* leave default true */ }); + Assert.False(Props(cache).DisableShufflingEndpoint); + } + + // ── Serialization (set-able, post-init assignment) ──────────── + + [Fact] + public async Task Mapping_Serialization_MaxDepth() + { + var cache = await CreateWith(o => o.Serialization.MaxDepth = 16); + Assert.Equal(16, Props(cache).MaxDepth); + } + + [Fact] + public async Task Mapping_Serialization_MaxArrayLength() + { + var cache = await CreateWith(o => o.Serialization.MaxArrayLength = 5_000); + Assert.Equal(5_000, Props(cache).MaxArrayLength); + } + + [Fact] + public async Task Mapping_Serialization_MaxBytesLength() + { + var cache = await CreateWith(o => o.Serialization.MaxBytesLength = 50_000); + Assert.Equal(50_000, Props(cache).MaxBytesLength); + } + + [Fact] + public async Task Mapping_Serialization_MaxStringLength() + { + var cache = await CreateWith(o => o.Serialization.MaxStringLength = 8_000); + Assert.Equal(8_000, Props(cache).MaxStringLength); + } + + // ── Build-time snapshot semantics ───────────────────────────── + + [Fact] + public async Task PostBuildMutation_OnCapturedOptions_DoesNotAffectCache() + { + // Caller holds a ref to the GeodeClientOptions they configured; + // mutating it after CreateAsync returns must not change the + // cache's behaviour (cppcache "geode.properties → SystemProperties + // at cache build" snapshot model). + GeodeClientOptions? captured = null; + await using var sp = BuildSp(); + var cache = await sp.GetRequiredService() + .CreateAsync("c", + (o, _) => + { + o.Serialization.MaxArrayLength = 100; + captured = o; + }, + TestContext.Current.CancellationToken); + + Assert.Equal(100, Props(cache).MaxArrayLength); + + // Late mutation on the original options bag. + captured!.Serialization.MaxArrayLength = 999_999; + + // Cache stays on the build-time snapshot. + Assert.Equal(100, Props(cache).MaxArrayLength); + } + + // ── Helpers ─────────────────────────────────────────────────── + + /// Shorthand: build factory + cache with . + private static async Task CreateWith(Action configure) + { + var sp = BuildSp(); + var cache = await sp.GetRequiredService() + .CreateAsync("c", (o, _) => configure(o), TestContext.Current.CancellationToken); + // sp stays alive for the lifetime of the test method via cache holding refs. + return cache; + } +} diff --git a/tests/Geode.Client.Tests/Services/IGeodeCacheFactoryTests.cs b/tests/Geode.Client.Tests/Services/IGeodeCacheFactoryTests.cs new file mode 100644 index 0000000..bd80e61 --- /dev/null +++ b/tests/Geode.Client.Tests/Services/IGeodeCacheFactoryTests.cs @@ -0,0 +1,183 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Services; + +public class IGeodeCacheFactoryTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + // ── CreateAsync ─────────────────────────────────────────────── + + [Fact] + public async Task CreateAsync_NewName_ReturnsNonNullCache() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + var cache = await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + Assert.NotNull(cache); + } + + [Fact] + public async Task CreateAsync_DuplicateName_ThrowsInvalidOperationException() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + () => factory.CreateAsync("foo", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CreateAsync_AfterDispose_ThrowsObjectDisposedException() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + await factory.DisposeAsync(); + + await Assert.ThrowsAsync( + () => factory.CreateAsync("foo", TestContext.Current.CancellationToken)); + } + + // ── Get / TryGet ────────────────────────────────────────────── + + [Fact] + public async Task Get_AfterCreate_ReturnsSameInstance() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var created = await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + var retrieved = factory.Get("foo"); + + Assert.Same(created, retrieved); + } + + [Fact] + public async Task Get_UnknownName_ThrowsKeyNotFoundException() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + Assert.Throws(() => factory.Get("nope")); + } + + [Fact] + public async Task TryGet_AfterCreate_ReturnsTrueWithCache() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var created = await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + var found = factory.TryGet("foo", out var cache); + + Assert.True(found); + Assert.Same(created, cache); + } + + [Fact] + public async Task TryGet_UnknownName_ReturnsFalseAndNullCache() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + var found = factory.TryGet("nope", out var cache); + + Assert.False(found); + Assert.Null(cache); + } + + // ── CacheNames ──────────────────────────────────────────────── + + [Fact] + public async Task CacheNames_Initially_Empty() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + Assert.Empty(factory.CacheNames); + } + + [Fact] + public async Task CacheNames_ListsCreatedCaches() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + await factory.CreateAsync("bar", TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "bar", "foo" }, factory.CacheNames.OrderBy(n => n)); + } + + [Fact] + public async Task CacheNames_AfterDispose_ThrowsObjectDisposedException() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + await factory.DisposeAsync(); + + Assert.Throws(() => factory.CacheNames); + } + + // ── DisposeCacheAsync ───────────────────────────────────────── + + [Fact] + public async Task DisposeCacheAsync_ExistingName_ReturnsTrueAndRemoves() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + var removed = await factory.DisposeCacheAsync("foo"); + + Assert.True(removed); + Assert.False(factory.TryGet("foo", out _)); + } + + [Fact] + public async Task DisposeCacheAsync_UnknownName_ReturnsFalse() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + var removed = await factory.DisposeCacheAsync("nope"); + + Assert.False(removed); + } + + [Fact] + public async Task DisposeCacheAsync_AfterDispose_ThrowsObjectDisposedException() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + await factory.DisposeAsync(); + + await Assert.ThrowsAsync( + async () => await factory.DisposeCacheAsync("foo")); + } + + // ── DisposeAsync ────────────────────────────────────────────── + + [Fact] + public async Task DisposeAsync_Idempotent() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + await factory.DisposeAsync(); + await factory.DisposeAsync(); + } +} diff --git a/tests/Geode.Client.Tests/Services/IGeodeCacheTests.cs b/tests/Geode.Client.Tests/Services/IGeodeCacheTests.cs new file mode 100644 index 0000000..6604d3b --- /dev/null +++ b/tests/Geode.Client.Tests/Services/IGeodeCacheTests.cs @@ -0,0 +1,106 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Services; + +public class IGeodeCacheTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task Name_MatchesCreateArgument() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + + var cache = await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + Assert.Equal("foo", cache.Name); + } + + [Fact] + public async Task PoolManager_IsNonNull() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var cache = await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + Assert.NotNull(cache.PoolManager); + } + + [Fact] + public async Task PoolManager_RepeatedReads_ReturnSameInstance() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var cache = await factory.CreateAsync("foo", TestContext.Current.CancellationToken); + + var first = cache.PoolManager; + var second = cache.PoolManager; + + Assert.Same(first, second); + } + + [Fact] + public async Task PoolManager_IsIsolatedPerCache() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + var cacheA = await factory.CreateAsync("a", ct); + var cacheB = await factory.CreateAsync("b", ct); + + Assert.NotSame(cacheA.PoolManager, cacheB.PoolManager); + } + + // ── CreateRegionFactory ─────────────────────────────────────── + + [Fact] + public async Task CreateRegionFactory_ReturnsNonNull() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var cache = await factory.CreateAsync("c", TestContext.Current.CancellationToken); + + var rf = cache.CreateRegionFactory(RegionShortcut.Proxy); + + Assert.NotNull(rf); + } + + [Fact] + public async Task CreateRegionFactory_ReturnsNewInstanceEachCall() + { + // No caching — each call yields a fresh builder so independent + // setter chains don't bleed into each other. + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var cache = await factory.CreateAsync("c", TestContext.Current.CancellationToken); + + var a = cache.CreateRegionFactory(RegionShortcut.Proxy); + var b = cache.CreateRegionFactory(RegionShortcut.Proxy); + + Assert.NotSame(a, b); + } + + [Fact] + public async Task CreateRegionFactory_AfterClose_ThrowsObjectDisposedException() + { + await using var sp = BuildSp(); + var factory = sp.GetRequiredService(); + var ct = TestContext.Current.CancellationToken; + var cache = await factory.CreateAsync("c", ct); + await cache.CloseAsync(ct); + + Assert.Throws(() => cache.CreateRegionFactory(RegionShortcut.Proxy)); + } +} diff --git a/tests/Geode.Client.Tests/Services/IPoolManagerTests.cs b/tests/Geode.Client.Tests/Services/IPoolManagerTests.cs new file mode 100644 index 0000000..75aff2e --- /dev/null +++ b/tests/Geode.Client.Tests/Services/IPoolManagerTests.cs @@ -0,0 +1,180 @@ +using Geode.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Geode.Client.Tests.Services; + +public class IPoolManagerTests +{ + private static ServiceProvider BuildSp() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + services.AddGeodeFactory(); + return services.BuildServiceProvider(); + } + + private static async Task BuildManagerAsync(ServiceProvider sp, CancellationToken ct) + { + var cache = await sp.GetRequiredService().CreateAsync("c", ct); + return cache.PoolManager; + } + + private static Task BuildPoolAsync(IPoolManager mgr, string name, CancellationToken ct) + => mgr.CreateFactory().AddServer("h", 40404).BuildAsync(name, ct); + + // ── DefaultPool ─────────────────────────────────────────────── + + [Fact] + public async Task DefaultPool_Initially_IsNull() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + + Assert.Null(mgr.DefaultPool); + } + + [Fact] + public async Task DefaultPool_AfterBuild_ReturnsFirstPool() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + var p1 = await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + + Assert.Same(p1, mgr.DefaultPool); + } + + [Fact] + public async Task DefaultPool_StaysFirst_AfterSecondBuild() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + var p1 = await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + var p2 = await BuildPoolAsync(mgr, "p2", TestContext.Current.CancellationToken); + + Assert.Same(p1, mgr.DefaultPool); + Assert.NotSame(p2, mgr.DefaultPool); + } + + // ── Find ────────────────────────────────────────────────────── + + [Fact] + public async Task Find_NullName_NoPools_ReturnsNull() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + + Assert.Null(mgr.Find()); + } + + [Fact] + public async Task Find_NullName_AfterBuild_ReturnsDefaultPool() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + var p1 = await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + + Assert.Same(p1, mgr.Find()); + } + + [Fact] + public async Task Find_UnknownName_ReturnsNull() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + + Assert.Null(mgr.Find("nope")); + } + + [Fact] + public async Task Find_KnownName_ReturnsThatPool() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + var p1 = await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + var p2 = await BuildPoolAsync(mgr, "p2", TestContext.Current.CancellationToken); + + Assert.Same(p1, mgr.Find("p1")); + Assert.Same(p2, mgr.Find("p2")); + } + + // ── GetAll ──────────────────────────────────────────────────── + + [Fact] + public async Task GetAll_Empty_Initially() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + + Assert.Empty(mgr.GetAll()); + } + + [Fact] + public async Task GetAll_AfterBuild_ContainsPools() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + var p1 = await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + var p2 = await BuildPoolAsync(mgr, "p2", TestContext.Current.CancellationToken); + + var all = mgr.GetAll(); + + Assert.Equal(2, all.Count); + Assert.Same(p1, all["p1"]); + Assert.Same(p2, all["p2"]); + } + + [Fact] + public async Task GetAll_ReturnsSnapshot_NotLiveView() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + + var snapshot = mgr.GetAll(); + await BuildPoolAsync(mgr, "p2", TestContext.Current.CancellationToken); + + // Snapshot taken before "p2" was added: it stays at 1 entry. + Assert.Single(snapshot); + Assert.Equal(2, mgr.GetAll().Count); + } + + // ── CloseAsync ──────────────────────────────────────────────── + + [Fact] + public async Task CloseAsync_Empty_DoesNotThrow() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + + await mgr.CloseAsync(ct: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CloseAsync_AfterBuild_ClearsRegistry() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + await BuildPoolAsync(mgr, "p1", TestContext.Current.CancellationToken); + + await mgr.CloseAsync(ct: TestContext.Current.CancellationToken); + + Assert.Null(mgr.Find("p1")); + Assert.Null(mgr.DefaultPool); + Assert.Empty(mgr.GetAll()); + } + + [Fact] + public async Task CloseAsync_Idempotent() + { + await using var sp = BuildSp(); + var mgr = await BuildManagerAsync(sp, TestContext.Current.CancellationToken); + + await mgr.CloseAsync(ct: TestContext.Current.CancellationToken); + await mgr.CloseAsync(ct: TestContext.Current.CancellationToken); + } +} diff --git a/tests/Geode.Client.Tests/SmokeTests.cs b/tests/Geode.Client.Tests/SmokeTests.cs deleted file mode 100644 index 0337b22..0000000 --- a/tests/Geode.Client.Tests/SmokeTests.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentAssertions; -using Xunit; - -namespace Geode.Client.Tests; - -public class SmokeTests -{ - [Fact] - public void TestInfrastructureWorks() - { - // Phase 0 sanity check. Replace once Phase 1 codec tests are added. - (1 + 1).Should().Be(2); - } -}