From 772ee0a106ab8c858c0d6d2344865cc1d5bf92cc Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Sun, 6 Sep 2026 01:16:47 -0600 Subject: [PATCH 1/9] added data file to store API objects and reaching out to API --- gui/src/data.odin | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 gui/src/data.odin diff --git a/gui/src/data.odin b/gui/src/data.odin new file mode 100644 index 0000000..bc7e4d3 --- /dev/null +++ b/gui/src/data.odin @@ -0,0 +1,35 @@ +package main + +Catalog :: struct { + name: string, + release: string, + description: ^string, + object_count: ^u64, +} + +CatalogObject :: struct { + id: string, + catalog: string, + object_type: string, + ra: f64, + dec: f64, + redshift: f64, +} + +APIError :: struct { + code: int, + message: string, +} + +get_catalogs :: proc() -> ([dynamic]Catalog, ^APIError) { + return nil, nil +} + +get_catalog :: proc(name: string) -> (^Catalog, ^APIError) { + return nil, nil +} + +get_catalog_objects :: proc(catalog_name: string) -> ([dynamic]CatalogObject, ^APIError) { + return nil, nil +} + -- 2.52.0 From 89a41262af1107038f5e1b7f37e525c65ed77297 Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Sun, 6 Sep 2026 13:52:39 -0600 Subject: [PATCH 2/9] updated procedures in data.odin to pass url; added research notes for data-streaming --- gui/src/data.odin | 12 +- .../data-streaming/01-architecture.md | 158 ++++++++++ .../data-streaming/02-flatbuffers-overview.md | 174 +++++++++++ .../data-streaming/03-rust-integration.md | 267 +++++++++++++++++ .../04-odin-client-integration.md | 218 ++++++++++++++ .../data-streaming/05-streaming-protocols.md | 278 ++++++++++++++++++ .../data-streaming/06-schema-design.md | 240 +++++++++++++++ .../data-streaming/07-alternatives.md | 150 ++++++++++ .../data-streaming/08-testing-strategies.md | 240 +++++++++++++++ .../research/data-streaming/09-pain-points.md | 199 +++++++++++++ .../10-performance-benchmarks.md | 147 +++++++++ .../ai/research/data-streaming/README.md | 85 ++++++ 12 files changed, 2165 insertions(+), 3 deletions(-) create mode 100644 resources/ai/research/data-streaming/01-architecture.md create mode 100644 resources/ai/research/data-streaming/02-flatbuffers-overview.md create mode 100644 resources/ai/research/data-streaming/03-rust-integration.md create mode 100644 resources/ai/research/data-streaming/04-odin-client-integration.md create mode 100644 resources/ai/research/data-streaming/05-streaming-protocols.md create mode 100644 resources/ai/research/data-streaming/06-schema-design.md create mode 100644 resources/ai/research/data-streaming/07-alternatives.md create mode 100644 resources/ai/research/data-streaming/08-testing-strategies.md create mode 100644 resources/ai/research/data-streaming/09-pain-points.md create mode 100644 resources/ai/research/data-streaming/10-performance-benchmarks.md create mode 100644 resources/ai/research/data-streaming/README.md diff --git a/gui/src/data.odin b/gui/src/data.odin index bc7e4d3..e82b36f 100644 --- a/gui/src/data.odin +++ b/gui/src/data.odin @@ -21,15 +21,21 @@ APIError :: struct { message: string, } -get_catalogs :: proc() -> ([dynamic]Catalog, ^APIError) { +get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) { return nil, nil } -get_catalog :: proc(name: string) -> (^Catalog, ^APIError) { +get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) { return nil, nil } -get_catalog_objects :: proc(catalog_name: string) -> ([dynamic]CatalogObject, ^APIError) { +get_catalog_objects :: proc( + url: string, + catalog_name: string, +) -> ( + [dynamic]CatalogObject, + ^APIError, +) { return nil, nil } diff --git a/resources/ai/research/data-streaming/01-architecture.md b/resources/ai/research/data-streaming/01-architecture.md new file mode 100644 index 0000000..b649458 --- /dev/null +++ b/resources/ai/research/data-streaming/01-architecture.md @@ -0,0 +1,158 @@ +# 01 — Current Architecture and Where FlatBuffers Fits + +## What exists today + +### API (Rust + axum) + +- **Entrypoint**: `api/src/main.rs` — binds `API_BIND_ADDR` (default `0.0.0.0:8080`), + wires `tracing`, serves the axum app with graceful shutdown. +- **Routes** (`api/src/routes/`): + - `GET /health` → static `{"status":"ok"}` + - `GET /api/v1/catalogs` → hardcoded list of 2 catalogs (`edr`, `dr1`) + - `GET /api/v1/objects?catalog=&limit=` → **stubbed**, returns `[]` +- **Models** (`api/src/models.rs`): + +```rust +pub struct Catalog { + pub name: String, + pub release: String, + pub description: &'static str, + pub object_count: Option, +} + +pub struct CatalogObject { + pub id: String, + pub catalog: String, + pub object_type: String, // galaxy / quasar / star + pub ra: f64, // degrees + pub dec: f64, // degrees + pub redshift: f64, // dimensionless +} +``` + +- **Serialization**: serde `Serialize` derives + `axum::Json`. `serde_json` is a + dev-dependency only (used by integration tests). +- **CORS**: `tower-http` is compiled with `cors` + `trace` features, but no CORS + layer is currently added to `routes::app()`. This matters for the WASM GUI. + +### GUI (Odin + raylib) + +- **Entrypoint**: `gui/src/main.odin` — `update()` → `draw()` loop, orbital + `Camera3D`, 4,000 procedurally generated points via `make_universe()`. +- **Placeholder data**: `Galaxy { position: rl.Vector3, color: rl.Color }` + generated in a 500-unit-radius sphere. Colors are a distance-based redshift + stand-in. +- **Data layer**: `gui/src/data.odin` defines hand-written Odin structs that + mirror the Rust API JSON contract (including pointer types `^string`, `^u64` + to mirror Rust `Option`), plus stubbed procedures: + +```odin +get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) // nil, nil +get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) // nil, nil +get_catalog_objects :: proc(url: string, catalog_name: string) // nil, nil +``` + +- **No HTTP client exists** on the Odin side — no `core:net`, no curl bindings, + nothing wired into the render loop. The structs are a *planned* interface. +- **Web build**: `gui/www/` — WASM shell (Emscripten) loading `index.wasm` + + `odin.js`, no API URL wiring yet. + +## Data flow gap + +``` +[DESI catalog store] --(future)--> [Rust/axum API] --(nothing today)--> [Odin + raylib GUI] + ^ ^ + | serde JSON models | hand-mirrored structs + | | (stubs, never used) +``` + +There is **no live data flow**. The API currently returns JSON placeholders; the +GUI renders procedural points. The intended shape (per code comments): +- The /api/v1/objects endpoint "will page through the centralized DESI catalog store." +- Real survey data replaces the procedural cloud "once ingestion lands." + +## Where FlatBuffers fits + +There are three natural insertion points: + +### 1. Wire format for the object-stream endpoint (primary fit) + +Replace the JSON array response of `GET /api/v1/objects` (or add an +`application/flatbuffer` variant / new `/api/v1/stream` endpoint) with a framed +stream of FlatBuffer messages: + +- **Rust side**: build FlatBuffers with `FlatBufferBuilder` per batch (e.g. + 10k-50k objects per batch), send as `application/octet-stream`. +- **Odin side**: receive the byte blob, verify once, and access `ra`/`dec`/ + `redshift` **directly from the network buffer** — no per-field copy. This is + the killer use case for the render loop, where every point is projected and + colored per frame. + +### 2. Catalog metadata (secondary fit) + +`GET /api/v1/catalogs` returns tiny JSON today. The FlatBuffers win here is +marginal (5-10 records), but using the *same* serialization everywhere keeps the +codebase uniform — one schema, one reader path. Recommend serving both a JSON +response (for curl/debugging) and a FlatBuffers variant (for the client). + +### 3. Bidirectional client → server channel (future fit) + +If the GUI ever sends selection/filter requests (pan region, redshift range, +object type filters), a WebSocket carrying FlatBuffers both ways gives +consistent framing. The `get_catalog_objects()` stub signature suggests the GUI +is expected to query **on demand** as the camera moves — a WebSocket request/ +response protocol with a cheap binary payload would fit this well. + +## Integration points (concrete anchors) + +| Layer | Today | FlatBuffers insertion point | +|---|---|---| +| Rust models | `Catalog`, `CatalogObject` (serde) | Generated `catalog_generated.rs` alongside or replacing | +| Rust routes | `routes/catalogs.rs` | New route (e.g. `/ws/catalogs`) or response-format negotiation | +| Rust Cargo | `serde`, `axum::Json` | `flatbuffers` crate + `flatbuffers-build` for `.fbs → .rs` | +| Odin structs | `data.odin` mirrors JSON | Generated/FFI FlatBuffers reader; or hand-rolled reader | +| Odin HTTP | none | `core:net` HTTP, WebSocket, or curl FFI | +| Build | `make build`, `make test` per project | Schema shared & compiled by both sides | + +## High-level target architecture + +``` + catalog.fbs (single source of truth, checked into repo) + | + +--------+---------+ + | | +flatc --rust flatcc --c (or hand-rolled Odin reader) + | | +api/ (Rust) gui/ (Odin + raylib) + | ^ + | HTTP / WebSocket (framed FlatBuffer binary stream) + +------------------+ +``` + +- One schema file. Two generators. Byte-for-byte identical wire format. +- A shared conformance test suite (see `08-testing-strategies.md`) ensures both + sides stay in sync. + +## Key architectural decisions to make later + +1. **Transport**: HTTP chunked/range requests vs. WebSocket vs. both + (see `05-streaming-protocols.md`). +2. **Framing**: size-prefixed FlatBuffers (`size_prefixed_root_*`) vs. a custom + length prefix (e.g. flatstream's 4-byte LE length). Needed because a TCP/ + WebSocket stream is byte-ambiguous — you must know where one message ends and + the next begins. +3. **Batching**: per-object tables vs. packed tables of arrays (columnar-style). + For millions of DESI objects, a `table { ra: [double]; dec: [double]; ... }` + layout is dramatically smaller and faster than 1 table per object. +4. **Schema ownership**: options include a top-level `schema/` directory shared + by both `api/` and `gui/`, checked-in generated code, or generated at build + time. Checked-in generated code is the simplest CI-safe option for both + `cargo build` and `odin build` isolation. + +## What to read next + +- `02-flatbuffers-overview.md` — the format's internals and guarantees +- `03-rust-integration.md` — how the Rust side consumes `.fbs` +- `04-odin-client-integration.md` — how the Odin side consumes the same format +- `05-streaming-protocols.md` — transport and framing choices +- `06-schema-design.md` — proposed DESI schema \ No newline at end of file diff --git a/resources/ai/research/data-streaming/02-flatbuffers-overview.md b/resources/ai/research/data-streaming/02-flatbuffers-overview.md new file mode 100644 index 0000000..e3549c3 --- /dev/null +++ b/resources/ai/research/data-streaming/02-flatbuffers-overview.md @@ -0,0 +1,174 @@ +# 02 — FlatBuffers Overview + +## What it is + +FlatBuffers is a **cross-platform serialization library** created by Google, +originally for game development and other performance-critical applications. Its +defining feature is **zero-copy data access**: you can read fields directly out +of the serialized byte buffer without parsing, unpacking, or allocating objects. + +- **Homepage**: https://flatbuffers.dev +- **GitHub**: https://github.com/google/flatbuffers +- **License**: Apache 2.0 +- **Schema language**: `.fbs` files (similar to `.proto` in spirit, but different) +- **Current version**: 25.12.19 (Dec 2025 on crates.io — ships with `flatc`) + +## Core guarantees + +| Property | Description | +|---|---| +| Zero-copy reads | Fields are accessed in-place via offsets — no deserialization step | +| Zero-alloc reads | Reading a buffer allocates nothing | +| Cross-language | Same schema compiles to C++, C, Rust, Go, Java, C#, JS/TS, Python, Swift, Dart, PHP, Lobster, Lua | +| Forward compatible | Code compiled against schema V1 reads data written with schema V2 (ignores unknown fields) | +| Backward compatible | Code compiled against schema V2 reads data written with schema V1 (returns defaults for missing fields) | +| Little-endian, fixed-width | Deterministic cross-platform serialization (offsets are u32 LE) | +| mmap-friendly | Works with memory-mapped files; only touched bytes are paged in | +| Thread-safe reads | Reading requires no synchronization; tables implement `Send + Sync` | + +## How the format works internally + +A FlatBuffer is a **contiguous binary blob** of nested objects arranged so the +data can be traversed in place like a pointer-based structure. The key concepts: + +### Tables + vtables + +- A **table** is an object whose fields are optional. It carries an inline + `soffset_t` (signed 32-bit) pointing at a **vtable**. +- The **vtable** maps field slots to (offset, presence) pairs. If a field is + absent (written by older code), the accessor returns the schema's default. +- Multiple tables with the same layout **share the same vtable** — this is where + the zero-copy "no per-object metadata" win comes from. +- Tables are the *only* construct that supports schema evolution. + +### Structs + +- A **struct** is a "naked" fixed-layout record with **no vtable** and **no + per-field optionality** (every field required). +- Layout is deterministic across platforms: scalars aligned to their own size; + struct aligned to its largest member. +- Smaller and faster than tables, but **cannot evolve** — fields can't be added + or removed. +- Perfect for things like `Vec3`, `Color`, or a coordinate record that will + never change. Example: DESI's `ra`/`dec`/`redshift` triplet could be a struct, + or (for bulk data) arrays of raw doubles. + +### Vectors + +- Native vector type (unlike protobuf's `repeated`). Length-prefixed, contiguous, + and for scalars/structs can be accessed via `safe_slice()` returning a + `&[T]` view (on little-endian machines) — **zero-copy slice access**. +- This is the single most important feature for DESI render workloads: + `table { ra: [double]; dec: [double]; z: [double] }` gives you three + `&[f64]` slices straight from the wire buffer. + +### Unions + +- A `union` is a type-discriminated tagged field (a `Type` enum + value). +- Used for things like `ServerMessage` that can carry different payloads + (catalog list, object chunk, error). + +### File identifiers + size prefixes + +- **File identifier**: up to 4 ASCII chars stored at offset 4, enabled in the + schema with `file_identifier "DESI"`. Lets a reader check "is this buffer a + DESI buffer" before touching it. +- **Size prefix**: a `u32` LE length written at the start of the buffer, + enabling stream framing (`size_prefixed_root_*`). This is the mechanism you + use to frame a stream of FlatBuffer messages. + +## Schema evolution rules (summary) + +These are the "thou shalt" rules for keeping buffers compatible: + +1. Add new fields **only at the end** of a `table`. (Or use explicit `id` + attributes on every field to allow reordering.) +2. Never remove a field — mark it `(deprecated)` instead. +3. Never change a field's type unless the width is identical *and* there are no + negative-value edge cases. +4. Never change a field's `default` after shipping (the default is baked into + generated code, not the buffer). +5. Enums: only ever add values, never remove. Handle unknown values defensively. +6. Structs: **cannot** gain or lose fields. Ever. +7. `required` on a non-scalar field: the verifier fails if it's absent. Adding + or removing `required` is a compatibility break. +8. Renaming fields/tables is safe for binary compat (buffers address by id, not + name) but breaks code that uses the old names. +9. Run `flatc --conform schema_v1.fbs schema_v2.fbs` to mechanically check that + `schema_v2` properly evolves from `schema_v1`. + +> A subtle trap from the official docs: **scalar fields equal to their default +> are NOT written to the buffer**. Non-presence is indistinguishable from "wrote +> the default". If presence matters (e.g. `object_count: Option` where +> `Some(0)` is meaningful) use **optional scalars** (`default: null`) or wrap the +> scalar in a struct. + +## Reading a buffer (conceptual) + +``` +bytes: &[u8] + + ┌─────────────────────────────┐ + │ uoffset (root table offset) │ + │ file_identifier (optional) │ + │ ... tables, vtables, data ...│ + └─────────────────────────────┘ + +root = follow(bytes) // jump to root table via uoffset +vtable = root - root.vtable_off // locate vtable for this table +field_ra = vtable.slot_ra != 0 // present? +if present: ra = read_f64(bytes, root + slot_ra) +``` + +There is **no parsing loop**. Each accessor is a few offset dereferences and a +read. The Rust runtime exposes this via a `Follow` trait that compiles to near-zero +code after optimization. + +## Verification (security) + +- The **verifier** walks a buffer and checks offsets, alignment, required fields, + and structural sanity *before* you access it. Protects against malicious or + corrupted buffers and out-of-bounds reads. +- Rust: `flatbuffers::root::(&opts, bytes)` runs the verifier (default + options); `root_unchecked::` skips it for trusted data. +- Available since the 25.x era in Rust; the C port (flatcc) has long had full + verifier support; vitally, Rust verification is now mature, so the "Rust has + no verifier" caveat from old docs is outdated. +- For untrusted network data (which is exactly your WebSocket case), **always + verify** before zero-copy access. +- Verifier limits (`VerifierOptions`) can cap `max_tables`, `max_depth`, etc. to + defend against resource-exhaustion attacks. + +## Buffer size limits + +- FlatBuffers uses `u32` offsets internally; a single buffer **cannot exceed + 2 GiB**. `FlatBufferBuilder` enforces `FLATBUFFERS_MAX_BUFFER_SIZE`. +- For DESI catalogs with millions of objects, this means **batch** (chunk) the + data — e.g. 100k objects per buffer — rather than one giant buffer. That + aligns naturally with streaming/pagination anyway. + +## Why it fits DESI Explorer + +- **Render-loop reads**: 100k Galaxy points → drop the network bytes into a + buffer, call `verify()`, then per frame iterate `ra[i]`, `dec[i], z[i]` as raw + `f64` slices. Zero allocations, zero GC pressure, cache-friendly contiguous + arrays. +- **Schema versioning across DESI releases**: `edr` data vs `dr1` vs `dr2` + differ in coverage/targets. Add `object_id` in release 2 at the end of the + table; release-1 clients still read old buffers fine. +- **Cross-language integrity**: the same `.fbs` compiles to Rust (API) and C/Odin + (GUI) with byte-identical wire format — no hand-maintained JSON contract drift, + which is exactly the `data.odin` mirror-struct problem today. +- **Single obvious streaming primitives**: size-prefixed buffers make framing + trivial; vectors make bulk data compact. + +## Official resources + +- Tutorial: https://flatbuffers.dev/tutorial/ +- Schema guide: https://flatbuffers.dev/schema/ +- Evolution rules: https://flatbuffers.dev/evolution/ +- Rust usage: https://flatbuffers.dev/languages/rust/ +- C (flatcc) usage: https://flatbuffers.dev/languages/c/ +- White paper: https://flatbuffers.dev/white_paper/ +- API docs (Rust): https://docs.rs/flatbuffers +- crates.io: https://crates.io/crates/flatbuffers \ No newline at end of file diff --git a/resources/ai/research/data-streaming/03-rust-integration.md b/resources/ai/research/data-streaming/03-rust-integration.md new file mode 100644 index 0000000..4629d58 --- /dev/null +++ b/resources/ai/research/data-streaming/03-rust-integration.md @@ -0,0 +1,267 @@ +# 03 — Rust / API Integration + +## Crate landscape + +### `flatbuffers` (runtime, official) + +- **crate**: `flatbuffers` — https://crates.io/crates/flatbuffers +- **Version**: 25.12.19 (Dec 2025). Note: project moved to CalVer in 2025 + (24.12, 25.1, 25.2, 25.9, 25.12.19). The old `2.1.x` line is legacy. +- **Support level**: labeled "experimental" historically, but is the official + Google runtime; used in production at Google and elsewhere. API stable enough + for real usage. +- **Features**: `serde` (optional), `std` (default), also supports `no_std`. +- **Dependencies**: `bitflags` only. +- **Downloads**: ~17M/month, 174 dependents — well maintained. + +### `flatbuffers-build` (build-time codegen, community) + +- **crate**: `flatbuffers-build` — wraps `flatc` so `.fbs → .rs` happens in + `build.rs` instead of manually. Requires `flatc` on PATH (or `FLATC_PATH`). +- **Caveat**: it writes generated code into a **symlinked directory under + `src/`** (`set_symlink_directory("src/generated")`) because Cargo can't include + `OUT_DIR` output as a module. This is a known hack; works but slightly ugly. +- **Alternative**: the `flatc` crate (`https://crates.io/crates/flatc`) vendors + a prebuilt `flatc` binary as a dependency. + +### `flatc` (vendored binary, community) + +- Crate `flatc 0.2.2+23.5.26` bundles the `flatc` executable. Let's you invoke + `flatc` from `build.rs` without requiring it system-wide. + +### `flatstream` (framing layer, community — very relevant) + +- **crate**: `flatstream` — https://github.com/dallasmarlow/flatstream-rs +- Adds a **framing layer** for streams of FlatBuffers: each frame is + `[4-byte LE length][optional checksum][payload]`. +- Zero-copy read via `StreamReader::process_all(&[u8])`. +- Originated as a high-frequency telemetry capture format. Composability: + bounded payload caps, checksum via xxhash64/crc32, observers, validators. +- **Sync IO only** (std `Read`/`Write`). Not async — you'd pair it with + `tokio::task::spawn_blocking` for network use, or just write your own framing + (it's ~20 lines). +- Note: the crate is young (2025) and small. Treat as a reference pattern, not + a hard dependency. A size-prefixed FlatBuffer + (`builder.finish_size_prefixed(root, Some("ID"))`) already gives you framing + for free if you only need one message type per stream. + +### Choice matrix + +| Need | Crate | Notes | +|---|---|---| +| Runtime read/write | `flatbuffers` | Required regardless | +| `.fbs → .rs` manually | `flatc` binary | Simple, deterministic, scriptable | +| `.fbs → .rs` in build.rs | `flatbuffers-build` | Needs flatc on PATH; symlink hack | +| Vendored flatc binary | `flatc` crate | Avoids system install | +| Stream framing | `flatstream` (or hand-rolled) | 4-byte LE length prefix pattern | +| Testing support | `flatbuffers` verification + `cargo test`, `proptest`, `quickcheck` | See testing doc | + +## Codegen options for the API + +### Option A — Commit generated code (recommended starting point) + +```bash +flatc --rust -o api/src/generated catalog.fbs +``` + +Then `mod catalog_generated;` in `lib.rs`. Pros: dead simple, zero build +scripts, works with `cargo test` out of the box, diffable in code review. Cons: +regenerate manually on schema change, risk of stale generated code. + +### Option B — build.rs codegen + +```rust +// api/build.rs +fn main() { + let schemas = ["../schema/catalog.fbs"]; + flatbuffers_build::BuilderOptions::new_with_files(&schemas) + .set_symlink_directory("src/generated") + .compile() + .expect("flatbuffer compilation failed"); +} +``` + +Pros: automatic regeneration. Cons: symlink hack, `flatc` must be installed +(CI needs it too), `cargo::rerun-if-changed` wiring is manual-ish. For a repo +that already pins toolchains in CI, adding `flatc` to `.gitea/workflows/ci.yml` +is mechanical. + +### Option C — shared schema + make target + +The project's build is `make`-based and mono-repo style. A root target like + +```make +schema-gen: + flatc --rust -o api/src/generated ../schema/catalog.fbs + flatcc --c -o gui/src/generated ../schema/catalog.fbs +``` + +run before `make build`/`make test` keeps both sides in sync from **one +schema file**. This fits the existing "root Makefile delegates" pattern well. + +## Recipe: serving FlatBuffers from axum + +### Dependencies + +```toml +[dependencies] +axum = { version = "0.8", features = ["ws"] } # ws feature for WebSocket +flatbuffers = "25.12.19" # runtime +# for build.rs codegen (if you choose Option B): +[build-dependencies] +flatbuffers-build = "0.2" # or use `flatc` binary option +``` + +### Endpoint that returns a FlatBuffer body (content-negotiated) + +```rust +use axum::{body::Bytes, extract::Query, http::StatusCode, response::{IntoResponse, Response}}; +use flatbuffers::{FlatBufferBuilder, WIPOffset}; + +mod catalog_generated; +use catalog_generated::desi::{CatalogObject, CatalogObjectArgs, ObjectBatch, ObjectBatchArgs, + finish_object_batch_buffer, root_as_object_batch}; + +async fn objects(query: Query) -> Response { + let rows = fetch_desi_objects(&query.catalog, query.limit.clamp(1, 100_000)).await; + + let mut fbb = FlatBufferBuilder::with_capacity(1 << 20); + let ra = fbb.create_vector(&rows.ra); + let dec = fbb.create_vector(&rows.dec); + let z = fbb.create_vector(&rows.z); + let ids = fbb.create_vector(&rows.id_offsets); + + let batch = ObjectBatch::create(&mut fbb, &ObjectBatchArgs { + catalog: Some(fbb.create_string(&rows.catalog)), + n: rows.len as u64, + ra: Some(ra), + dec: Some(dec), + redshift: Some(z), + ids: Some(ids), + ..Default::default() + }); + finish_object_batch_buffer(&mut fbb, batch); + + ([(header::CONTENT_TYPE, "application/octet-stream")], Bytes::copy_from_slice(fbb.finished_data())) + .into_response() +} +``` + +### WebSocket endpoint + +```rust +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + response::Response, + routing::any, +}; +use futures_util::{stream::StreamExt, sink::SinkExt}; + +async fn ws_handler(ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(handle_socket) +} + +async fn handle_socket(mut socket: WebSocket) { + // On connect, stream catalog batches as Binary(Message::Binary(...)) frames: + // each frame's payload is a size-prefixed or length-prefixed FlatBuffer. + while let Some(Ok(msg)) = socket.recv().await { + match msg { + Message::Text(query) => { + // Client asked for a catalog region; stream batch FlatBuffers back. + let rows = fetch_region(&query).await; + for chunk in rows.chunks(50_000) { + let buf = build_object_batch_flatbuffer(chunk); + socket.send(Message::Binary(buf.into())).await.unwrap(); + } + } + Message::Close(_) => break, + _ => {} + } + } +} +``` + +Notes: +- WebSocket **binary messages** (`Message::Binary`) are exactly-typed `Vec`/ + `Bytes` — ideal for FlatBuffer payloads with no extra encoding. +- Use `WebSocketUpgrade.max_message_size(...)` to cap client message size + (default 64 MB). +- For a single server pushing to many clients, use `tokio::sync::broadcast` and + split the socket (`socket.split()` → `SinkExt` for sending, `StreamExt` for + receiving) — see `05-streaming-protocols.md`. +- If the Odin client is WASM (browser), CORS matters: add `tower-http`'s + `CorsLayer` since the WASM origin differs from the API origin. WebSockets + aren't CORS-restricted the same way HTTP is, but the HTTP upgrade handshake + still goes through the middleware stack. + +## Building buffers efficiently (Rust specifics) + +- `FlatBufferBuilder::with_capacity(n)` pre-allocates; `reset()` reuses the + buffer across messages. In a loop streaming batches, create one builder, reuse + it — avoid repeated reallocation. +- Builder methods have `try_*` counterparts (`try_create_string`, + `try_push`, `try_finish`) that return `Result` instead of panicking on + out-of-capacity — useful on the server under memory pressure. +- `with_internal_capacity(size, field_locs, vtables, strings)` preallocates all + internal data structures — removes allocation-related latency spikes in a + latency-sensitive server path. Combined with `reset()`, a long-lived stream + handler does **zero allocations** after startup. +- For bulk numeric data, prefer **vectors of scalars** (columnar layout) over + `create_vector` of tables. `create_vector` on `&[f64]` is a fast path; on + little-endian it back-fills with scalar offsets, enabling `safe_slice` + reads. Result: one contiguous `&[f64]` slice per column on the client. +- Building is **back-to-front**: children (strings, vectors, nested tables) + must be created before the parent table referencing them. The `*Args` struct + pattern (`ObjectBatchArgs { .. }`) makes this manageable, but be deliberate + about creation order. +- `builder.finished_data()` returns `&[u8]` borrowing the builder — copy to an + owned `Bytes` for the response, then `reset()`. + +## Serialization on the server (axes to optimize) + +- **Batch size tradeoff**: each batch carries one vtable + one table header; + amortize over more rows. Choose batch size so the buffer is well under the + 2 GiB limit and matches your streaming cadence (e.g. 50k objects ≈ ~3 MB). +- **CPU cost**: FlatBuffers serialization is fast but not free (it's the + read-side where FlatBuffers shines). Measure with criterion; if serialization + becomes the bottleneck, move builder work to a dedicated worker thread / + `spawn_blocking` and hand finished batches to the WebSocket sender via a + channel. +- **Vtables**: tables with identical field layouts share vtables automatically — + batching objects with the same schema shape is free (they all share). + +## Integration with existing serde models + +You don't have to delete serde. Pragmatic path: + +- Keep `CatalogObject` (serde) for JSON endpoints / debugging / curl-ability. +- Add a **separate** FlatBuffers representation for the binary path, generated + from the shared `.fbs`. +- Add a `From for ObjectBatchArgs`-style conversion, or better: + build FlatBuffers **directly from the raw DESI rows** (skip the serde struct + entirely, avoiding a serialize-then-rebuild pass). The DESI store should hand + you vectors of `f64`; feed those straight into `create_vector`. + +This preserves today's test shape (`api/tests/health.rs` parses JSON) and adds +the binary path without churn. + +## CI / build concerns for this repo + +- The Gitea workflow (`ci.yml`) runs `cargo test` + clippy. If you switch to + build.rs codegen, CI needs `flatc` installed (or use the vendored `flatc` + crate). If you commit generated code, CI needs nothing extra — one more reason + Option A (commit generated code) is the lowest-friction start. +- Pin the **same schema file** for both API and GUI. If the schema lives in a + shared `schema/` directory and both sides' Makefiles depend on it, `make test` + stays the single verification step: Rust tests + Odin tests + Go tests each + validate their own pipeline against the shared contract. +- Mind the repo convention: `make -C api check` is the passthrough for + `cargo test` + clippy. Keep the FlatBuffers stack incrementally addable here. + +## References + +- https://flatbuffers.dev/languages/rust/ — official Rust usage doc +- https://docs.rs/flatbuffers — runtime API reference +- https://github.com/frol/flatc-rust — flatc-as-build-API (alternative) +- https://github.com/dallasmarlow/flatstream-rs — framing reference +- https://crates.io/crates/flatbuffers-build — codegen crate \ No newline at end of file diff --git a/resources/ai/research/data-streaming/04-odin-client-integration.md b/resources/ai/research/data-streaming/04-odin-client-integration.md new file mode 100644 index 0000000..52f9c98 --- /dev/null +++ b/resources/ai/research/data-streaming/04-odin-client-integration.md @@ -0,0 +1,218 @@ +# 04 — Odin / GUI Integration + +**This is the highest-risk area of the whole research effort.** FlatBuffers has +**no first-party Odin binding**. You must choose one of several integration +paths. This doc lays out the realistic options with tradeoffs, plus the +networking story for the GUI. + +## The core problem + +Odin can talk to C via `foreign` blocks (the C ABI is first-class). FlatBuffers +has two C-related access points: + +1. **Google's official `flatc`** generates C++ and C (C#/Java/etc.) — but its + C support is a *separate project*, **FlatCC**. +2. **FlatCC** (`dvidelabs/flatcc`) is an independent FlatBuffers compiler + + runtime for **pure C**. It generates `_reader.h`/`_builder.h` headers per + schema plus a small `libflatccrt.a` runtime. Works via the C ABI, so Odin's + `foreign import` can consume it. + +| Path | Effort | Zero-copy on reads | Notes | +|---|---|---|---| +| A: FFI to FlatCC (C runtime) | Medium | ✅ | Bind generated C headers to Odin `foreign` | +| B: Pure-Odin reader (hand-rolled) | High | ✅ | Port a minimal reader; no C dependency | +| C: OdinArrow reuse | Medium-High | ✅ | OdinArrow already has hand-rolled FlatBuffers encoder/decoder | +| D: TS/JS interop (WASM-only) | Low-N/A | Partial | Browser builds can read FlatBuffers via JS lib instead | + +### Path A — FFI to FlatCC (recommended starting point) + +**Mechanics.** FlatCC generates per-schema C headers. You: + +1. Install/build flatcc (it's a small C project, `flatbuffer`-compatible). +2. Generate C reader+builder headers from your `.fbs`: + ```bash + flatcc --common -a schema/catalog.fbs -o gui/src/generated + ``` + yields `catalog_reader.h`, `catalog_builder.h`, `catalog_verifier.h`, plus a + `flatccrt.h`/`libflatccrt.a` runtime. +3. Write (or generate with `odin-c-bindgen` / + `Breush/odin-binding-generator`) Odin foreign bindings for the handful of + functions your GUI actually uses. + +Illustrative Odin binding shape (rough, not final API): + +```odin +package catalog_fb + +foreign import flatcc "libflatccrt.a" + +@(default_calling_convention = "c") +foreign flatcc { + // table accessors generated by flatcc look like: + flatbuffers_verify_buffer :: proc(buf: rawptr, size: uint, id: ^byte) -> c.int --- + catalog_Catalog_object_count :: proc(t: ^catalog_Catalog_table) -> u64 --- + catalog_CatalogObject_ra :: proc(t: ^catalog_CatalogObject_table) -> f64 --- + // etc. +} +``` + +Zero-copy: flatcc's generated **reader** macros operate directly on the buffer — +`ra()` is a macro expanding to a bounds-checked buffer read. That maps naturally +to Odin's `#foreign` + cstring/`^f64` access. + +**Downsides:** +- Odin bindings must **track schema regeneration**. Every time you add a field + to the `.fbs`, the C headers change and the Odin foreign decls (or the + generated bindings) must be refreshed. +- flatcc's API is oriented to C macros; binding it faithfully through Odin + `foreign` is doable but fiddly (macros don't transfer — you translate each + macro into the equivalent C function or hand-roll the offset arithmetic). +- You now have a C runtime (static lib) in the GUI build. For the **WASM + target** this must compile under Emscripten — flatcc is plain C and does build + for WASM, but adds to WASM binary size and toolchain coupling. + +**When to choose:** when you want a *proven* library, don't mind C in your +Odin build, and want to avoid writing and maintaining a reader yourself. + +### Path B — Pure-Odin reader (hand-rolled, most effort, most control) + +FlatBuffers read access is genuinely simple — as the ODINARROW project proves. +OdinArrow ships a "hand-rolled FlatBuffers encoder/decoder" for the Arrow IPC +header format. A minimal FlatBuffers table reader in Odin is ~100-300 lines: +follow `u32` offsets, deref vtables, read little-endian scalars. + +What you'd implement: +- `ReadRoot(root: ^u8, size: uint) -> root_offset` (uoffset at byte 0; skip + 4-byte length prefix if size-prefixed). +- Vtable lookup: given a table addr, read `uoffset` back to vtable, scan slots + for a field id, read field offset (or treat as absent → default). +- Vectors: u32 length + element stride; for `f64`/`f32`/structs this is a direct + `^f64` slice after bounds check. +- Verification: walk offsets checking bounds/alignment (or skip for trusted data + — but you're on a **network stream**; verify, at least bounds, before use). + +**Downsides:** +- You own correctness, update discipline, and testing. Every FlatBuffers format + nuance (file identifiers, size prefixes, unions) must be re-implemented. +- Risk of subtle divergence from the C++/Rust/Java implementations + (endianness, alignment, default-value semantics). +- Cross-language conformance tests (see `08-testing-strategies.md`) absolutely + required — you're re-implementing a spec. + +**When to choose:** when you want zero C in the Odin build, plan long-term +maintenance, and value full control (and you can lean on OdinArrow's already +proven patterns). + +### Path C — OdinArrow reuse (hybrid) + +- https://github.com/TimeLord/OdinArrow — a mature-ish Odin implementation of + Apache Arrow's IPC format, **including** a hand-rolled FlatBuffers + encoder/decoder (Arrow IPC metadata is FlatBuffers). +- You could extract/adapt OdinArrow's FlatBuffers decode machinery for your own + schema, or (bolder) adopt Arrow IPC entirely for the data path (Arrow IPC + *is* FlatBuffers-framed + columnar buffers — arguably an excellent fit for + streaming galaxy positions/redshifts). +- OdinArrow is a small, MIT-style community project (TimeLord). Verify license + and maintenance before depending on it. +- If you go pure Arrow IPC, you get batch semantics for free (schema message → + record batch messages) — same framing pattern as FlatBuffers with the + columnar layout built in. +- Arrow IPC stream = length-prefixed (u32 LE) messages, with a + `continuation marker 0xFFFFFFFF` for 4-byte alignment. This is a well-specified + framing you can reuse *without* adopting Arrow's data model. + +**When to choose:** when Arrow-style columnar data is actually what you want +(millions of numeric rows — it is a great fit), and you're okay depending on / +contributing to OdinArrow. + +### Path D — WASM/JS interop (browser build only) + +- The Web GUI (`gui/www/`) builds Odin to WASM and runs alongside JS. +- FlatBuffers has **official JS/TS support** (`flatbuffers` npm package). For + the web build you could do the parsing/decoding in JS (or TypeScript) and hand + plain arrays (`Float64Array`) to the Odin WASM side — losing zero-copy at the + WASM boundary but gaining ecosystem-tested parsing. +- Practical hybrid: **WASM build path**: keep the WebSocket in JS, decode + FlatBuffers in JS (official lib), then transfer typed arrays into WASM memory + (single `Emscripten.HEAPF64.set(...)` copy). Zero-copy is not preserved across + the WASM boundary, but the *raw-bytes → arrays* decode in JS is still far + cheaper than JSON and uses a battle-tested library. +- Native desktop build (`odin build` + raylib, the primary target): need one of + A/B/C. The WEB GUI is secondary. + +**Recommendation for this repo's roadmap:** Start with **Path B or A** for the +native Odin build (the primary `make run` target), and use **Path D** for the +WASM build if/when it becomes a shipping concern. The conformance-test suite +(shared static `.mon`/`.bin` fixture files read by both Rust and Odin) is the +safety net that makes the hand-rolled Path B safe. + +## Networking from Odin + +There is **no networking code in the GUI today**. Options for receiving +FlatBuffers from the Rust API: + +| Option | Fit | Notes | +|---|---|---| +| `core:net` (Odin stdlib) | Native desktop | Built-in `core:net` module has socket APIs; HTTP is manual or minimal — fine for `GET` of a binary body; WebSocket requires hand-rolling the upgrade + frame handling (doable, ~200 lines) | +| Curl FFI (`libcurl`) | Native desktop | Battle-tested HTTP, easy `buffer` callback for `application/octet-stream`; Odin `#foreign` to curl is well-trodden (e.g., furbs). Adds libcurl dep to native build | +| Emscripten `fetch` bridge | WASM | In the browser build, JS owns the network; call `fetch` from JS or via Odin's Emscripten bindings, then `HEAP`-copy | +| WebSocket via JS | WASM | Same as above for the browser | +| Community libs | Both | e.g. various `core:net`-based or `thirdparty` HTTP clients; vet for maturity | + +`gui/src/data.odin` already has the intended procedure signatures: + +```odin +get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) +get_catalog_objects :: proc(url: string, catalog_name: string) -> ([dynamic]CatalogObject, ^APIError) +``` + +When the transport is in place, these become the seam between the network layer +and the FlatBuffers decode layer: fetch bytes → verify → read fields → return +typed Odin slices. + +## ZSS / zero-copy in the render loop + +The rendering win only materializes if data stays zero-copy **into the frame +loop**: + +1. Fetch frame bytes → owned `[dynamic]u8` (or a slice pinned for the lifetime + of the frame). +2. `verify` the buffer once. +3. Get `ra_slice := ObjectBatch.ra(&buf)` → `[]f64` view. +4. Per object in `update()`/`draw()`: read `ra[i]`, `dec[i]`, `z[i]` straight + from that slice; build `rl.Vector3`; `DrawPoint3D`. + +No per-object allocation. The current `Galaxy { position, color }` dynamic array +in `main.odin` is the data structure you'd replace with *slices into the +FlatBuffer*. + +## WASM memory-model caveats + +- If JS decodes FlatBuffers and hands typed arrays to Odin/WASM, the copy into + WASM linear memory is one `HEAPF64.set()` — a single memcpy, not per-field. +- If Odin/WASM itself decodes FlatBuffers over its own `core:net` binding, it + reads directly from WASM heap — same as native, but you're now maintaining the + hand-rolled decoder in WASM too (subject to WASM's 32-bit indexing, still fine + for sub-2GiB buffers). +- Emscripten `-sALLOW_MEMORY_GROWTH` and 4GB heap settings matter if you plan + to hold multi-GB catalogs; keep to batched frames (e.g. ≤ 64 MB) and free per + chunk. + +## Build integration in this repo + +The `gui/Makefile` owns the Odin targets. Adding FlatBuffers means: +- A `-collection:lib=lib/local` (or a vendored submodule under `gui/lib/`) for + either the `flatcc` runtime lib or the hand-rolled Odin package. +- A `schema-gen` source of truth: run flatc for Rust + C (Path A) or otherwise + regenerate, as a make target (see README root / `03-rust-integration.md`). +- CI (`odin test` in the Gitea workflow) must pick up the new collection and any + generated files. Commit generated files to avoid CI toolchain surprises. + +## References + +- Odin FFI docs: https://odin-lang.org/docs/ffi/ (foreign blocks, calling conventions) +- Odin binding to C (official news post): https://odin-lang.org/news/binding-to-c/ +- FlatCC: https://github.com/dvidelabs/flatcc (and `flatbuffers.dev/languages/c/`) +- OdinArrow: https://github.com/TimeLord/OdinArrow (owned FlatBuffers + Arrow IPC) +- Odin binding generator (community): https://github.com/karl-zylinski/odin-c-bindgen +- msgpack-odin (reference for a hand-rolled binary codec in Odin, small): https://github.com/tgolsson/msgpack-odin \ No newline at end of file diff --git a/resources/ai/research/data-streaming/05-streaming-protocols.md b/resources/ai/research/data-streaming/05-streaming-protocols.md new file mode 100644 index 0000000..89a5798 --- /dev/null +++ b/resources/ai/research/data-streaming/05-streaming-protocols.md @@ -0,0 +1,278 @@ +# 05 — Streaming Protocols & Transport + +The wire isn't just the serialization — it's the **transport + framing**. This +doc covers how to move framed FlatBuffer messages from the Rust/axum API to the +Odin GUI. + +## High-level options + +| Transport | Full-duplex | Server push | Browser (WASM) support | Native Odin support | Fit for DESI Explorer | +|---|---|---|---|---|---| +| HTTP GET + paginated responses | No | No | Native `fetch` (works via JS bridging) | `core:net` / curl | Baseline; simplest start | +| HTTP chunked streaming (SSE-like) | No | One-way stream | `fetch` streaming / SSE | `core:net` | Good for one-shot bulk pushes, no client→server mid-stream control | +| WebSocket | Yes | Yes | Native browser API | Hand-rolled or FFI | **Best overall** for interactive pan/zoom + chunked object streaming | +| Raw TCP | Yes | Yes | N/A (browser can't do raw TCP) | `core:net` | Best perf but breaks the web target | + +**Recommendation: WebSocket for the interactive streaming path.** It supports +bidirectional messaging (client asks for a catalog region; server pushes chunks), +works in both native Odin and the browser, and axum has first-class `ws` support. + +## Framing — where one message ends and the next begins + +A byte stream gives you no message boundaries. You need framing. FlatBuffers +offers two built-in mechanisms plus the community pattern: + +### Option 1: Size-prefixed FlatBuffers (built-in) + +```rust +builder.finish_size_prefixed(root, Some("DESI")); +// +---------------------------+ +// | u32 LE: total buffer len | <-- size prefix +// | u32 LE: root table offset | +// | file identifier (4 bytes) | +// | ... data ... | +// +---------------------------+ +``` + +Reader side: + +```rust +// Rust +let msg = flatbuffers::size_prefixed_root::(&buf).unwrap(); +``` + +```odin +// Odin (hand-rolled) +length := read_u32_le(buf) // skip this many bytes for the payload +root_offset := read_u32_le(buf + 4) // root table starts here +``` + +Pros: zero framing code; built into the format. Cons: doesn't carry an explicit +checksum; one message type per stream (fine here unless you multiplex multiple +message kinds). + +### Option 2: Custom length-prefix framing (like `flatstream`) + +``` +[ u32 LE: message_len ] [ optional checksum (e.g. u32 crc/xxhash) ] [ flatbuffer payload ] +``` + +- `flatstream-rs` (see `03-rust-integration.md`) is a reference implementation + of exactly this pattern: length prefix + optional checksum + zero-copy reads. +- For a WebSocket you *could* rely on WebSocket frames as boundaries — each + `Message::Binary` is already a discrete frame. Simpler than custom framing: + **one WebSocket message = one FlatBuffer**. This is the lowest-effort and + totally viable option. FlatBuffers even recommends size-prefixed buffers for + streams, but if your transport already frames (WebSocket, HTTP chunked), the + extra size prefix is optional — though harmless to keep for uniformity. + +### Option 3: Batch as a single buffer (no framing needed) + +If each HTTP response / WebSocket message is a **complete** `ObjectBatch` +(columnar) FlatBuffer, you don't need in-band framing at all — the response +*is* one message. Framing only matters when you stream *multiple* messages per +connection. For DESI Explorer: +- **HTTP path**: one response = one batch → no extra framing. +- **WebSocket path**: one `Binary` message = one batch → no extra framing + (WebSocket frames give you boundaries). +- **Raw TCP path**: you need framing (Option 1 or 2). + +## Transport deep-dive + +### WebSocket (axum) + +Server: + +```rust +use axum::{ + extract::ws::{Message, WebSocket, WebSocketUpgrade}, + extract::State, + routing::any, + Router, +}; + +async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { + ws.max_message_size(64 * 1024 * 1024) // 64 MB + .on_upgrade(|socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: Arc) { + let (mut sender, mut receiver) = socket.split(); + + // Writer task: sends FlatBuffer batches from a broadcast channel + let mut rx = state.tx.subscribe(); + let send_task = tokio::spawn(async move { + while let Ok(batch) = rx.recv().await { + if sender.send(Message::Binary(batch.into())).await.is_err() { + break; // client disconnected + } + } + }); + + // Reader task: handles client requests (region query, filters) + while let Some(Ok(msg)) = receiver.next().await { + match msg { + Message::Text(q) => handle_query(q, &state), // triggers a push + Message::Close(_) => break, + _ => {} + } + } + send_task.abort(); +} +``` + +Key points: +- `Message::Binary(Bytes)` is the native carrier for FlatBuffer payloads. +- Use `tokio::sync::broadcast` for fan-out to many clients (window `Lagged` + errors; a slow client falls behind → notify instead of blocking). +- `max_message_size` and `max_frame_size` tunables matter for large batches. +- `WebSocketUpgrade` works over HTTP/1.1 `GET` upgrades; in a browser, the + connection is `ws://`/`wss://`. + +Client (Odin, native): + +```odin +import "core:net" + +// 1. Open TCP to host:port +sock, err := net.dial_tcp(endpoint) +// 2. Send the HTTP upgrade handshake: +// GET /ws HTTP/1.1 +// Host: ... +// Upgrade: websocket +// Connection: Upgrade +// Sec-WebSocket-Key: +// Sec-WebSocket-Version: 13 +// 3. Parse 101 Switching Protocols response. +// 4. Frame format (client→server): [fin|opcode=2 (binary)] [mask=1] [len] [mask-4B] [payload] +// 5. Server→client frames: [fin|opcode=2] [len] [payload] (no mask requirement) +``` + +- Odin `core:net` has no WebSocket built-in; hand-rolling the upgrade + simple + frame reader is ~150-250 lines for the *subset* you need (text in, binary + out). There are community implementations (e.g. furbs has a networking lib). +- For the WASM build, JS owns the WebSocket (`new WebSocket(url)`), delivers + `ArrayBuffer`s — you then copy the byte slice into WASM heap. See + `04-odin-client-integration.md` Path D. + +### HTTP streaming (alternative, simpler) + +Server: + +```rust +use axum::body::Body; +use axum::http::header::CONTENT_TYPE; +use futures_util::StreamExt; +use tokio::sync::mpsc; + +async fn stream_objects(Query(q): Query) -> Response { + let (tx, rx) = mpsc::channel::>(8); + tokio::spawn(async move { + for chunk in paginate_desi(q).await.unwrap().chunks(50_000) { + let _ = tx.send(build_object_batch_flatbuffer(chunk)).await; // backpressure via channel + } + }); + let stream = tokio_stream::wrappers::ReceiverStream::new(rx) + .map(|bytes| Ok::<_, std::io::Error>(Bytes::from(bytes))); + Response::new(Body::from_stream(stream)) +} +``` + +- Client reads length-prefixed messages from the response body. +- Simpler than WebSocket (no handshake/frames), but one-directional and no + mid-stream client control. + +### Raw TCP (maximum perf, breaks browser) + +- `core:net` TCP + length-prefixed frames. Low overhead, but the web GUI can't + participate. Only worth it if the native desktop build must outperform + WebSocket by a large margin (it won't in practice for this workload — network + bandwidth dominates, and WebSocket adds ~2-14 bytes/frame depending on size). + +## Message envelope design + +Even with transport framing, you'll likely multiplex message **kinds** over the +same stream (catalog list, object batch, error, ping). Two clean patterns: + +### Pattern A — size-prefixed buffer + `ServerMessage` union (fully self-describing) + +```fbs +union Message { + CatalogList, + ObjectBatch, + APIError, +} + +table ServerMessage { + msg: Message; // disc + catalog_list: CatalogList; + object_batch: ObjectBatch; + error: APIError; +} + +root_type ServerMessage; +``` + +The client `verify`s the whole buffer and pattern-matches the union tag. One +buffer format for everything. + +### Pattern B — per-kind size-prefixed buffers (simpler decoder) + +```fbs +root_type CatalogList; // separate finish buffer +root_type ObjectBatch; // separate finish buffer +root_type APIError; // separate finish buffer +``` + +Each message is a different root type. The client sniffs the file identifier +(sector size prefix `size_prefixed_root_with_opts`) to dispatch. Slightly less +structural safety, but each decoder is trivial. + +Recommendation: **Pattern A** if the stream carries mixed traffic (interactive +control + data + errors); **Pattern B** if the path is a pure object stream +(e.g. dedicated `/ws/objects` endpoints per kind). + +## Backpressure & chunking + +- **Chunk size**: batch ~ tens of thousands of objects (~1-4 MB) keeps latency + tight for interactive pan/zoom while amortizing per-buffer overhead. +- **mpsc channel**: cap in-flight buffered batches (e.g. 8) so a slow client + never piles up unbounded memory server-side. `tokio::sync::broadcast` instead + for fan-out, with `Lagged` handling. +- **Client**: buffer at most N in-flight frames; drop old detail when a new + region query supersedes an in-flight one. With zero-copy reads this is cheap: + each frame is one buffer you can free wholesale. +- **Cadence**: push an initial batch immediately on connect; then respond to + client region moves, not a blind rate-limited firehose. (Unless "streaming" + means full-survey playback — then a T-state machine with cursor/seq is better.) + +## HTTP-range / mmap alternative (worth remembering) + +If catalogs become **static cloud files** (like Apache Arrow / FlatCityBuf's +model), you can skip the API entirely for bulk data: ship pre-built `.fbs` files +and let the *client* do HTTP range requests straight from object storage. The +FlatCityBuf project (see references) does exactly this and is a working proof of +concept: spatial index stored beside the FlatBuffer file, `Range` requests fetch +only the slabs you need. + +This is probably a later-stage architecture, but it's the strongest argument for +FlatBuffers long-term (mmap-friendly, page-in-what-you-touch). + +## Decision summary for this repo + +1. Start with **HTTP GET → one FlatBuffer body per batch** to validate the Rust + builder + Odin reader (no protocol work at all). +2. Then add **WebSocket** with one `Binary` message per batch (no custom framing) + for the interactive path. +3. Keep messages **self-identifying** via file identifiers (`file_identifier "DESI"`). +4. Broaden later: hot spots can move to size-prefixed/`flatstream`-style framing + or even HTTP-range + mmap if catalogs go static. + +## References + +- axum ws docs: https://docs.rs/axum/latest/axum/extract/ws/index.html +- flatstream-rs (framing reference): https://github.com/dallasmarlow/flatstream-rs +- FlatCityBuf (HTTP-range + FlatBuffers + WASM proof-of-concept): + https://github.com/cityjson/flatcitybuf +- Apache Arrow IPC framing spec (continuation + length prefix pattern): + https://arrow.apache.org/docs/format/Columnar.html#ipc-streaming-format \ No newline at end of file diff --git a/resources/ai/research/data-streaming/06-schema-design.md b/resources/ai/research/data-streaming/06-schema-design.md new file mode 100644 index 0000000..adaeb88 --- /dev/null +++ b/resources/ai/research/data-streaming/06-schema-design.md @@ -0,0 +1,240 @@ +# 06 — Schema Design for DESI Data + +This doc sketches a FlatBuffers schema for DESI Explorer and the rationale +behind each decision. It's a *proposal* — validate against real DESI catalog +columns before finalizing. + +## Design goals + +1. **Compact bulk numeric streaming** — the dominant payload is millions of + (ra, dec, redshift) triples. Use **columnar vectors of scalars**, not one + table per object. This is what makes the performance win real (see + `10-performance-benchmarks.md`) — vectors of scalars are contiguous and can be + `safe_slice`'d into `&[f64]` views. +2. **Schema evolution** for future DESI releases (edr → dr1 → dr2, added + columns like `petro_mag`, `spectroscopic_class`, coverage tiles). +3. **Self-describing streams** via file identifiers and a message union. +4. **Keep the existing API contract** (`Catalog`, `CatalogObject` from + `api/src/models.rs`) as the JSON-facing mirror, so `curl`/debug paths stay. + +## Proposed schema + +```fbs +// schema/catalog.fbs +namespace desi; + +file_identifier "DESI"; + +// ---------- Catalog metadata ---------- + +table Catalog { + name: string; // "edr", "dr1", ... + release: string; // "Early Data Release" + description: string; + object_count: uint64; // Some(0) problem → see note below +} + +// ---------- Bulk object data (columnar) ---------- + +// A single point, for small results / per-row paths. +struct Point { + ra: double; // ICRS, degrees, [0, 360) + dec: double; // ICRS, degrees, [-90, 90] + redshift: double; // dimensionless + object_type: byte; // 0=galaxy 1=quasar 2=star (enum → byte) +} + +table ObjectBatch { + catalog: string; // catalog this batch belongs to + batch_seq: uint32; // for reassembling a paged stream in order + n: uint64; // number of objects in this batch + + // Columnar arrays — zero-copy friendly, contiguous per column. + ids: [uint64]; // object ids (or bytes for survey ids) + object_types: [byte]; // enum per object (galaxy/quasar/star) + ra: [double]; // degrees + dec: [double]; // degrees + redshift: [double]; // dimensionless + // future columns (release-2): petro_mag_g/r/z (optional vectors), + // is_spectroscopic: [bool], ... +} + +// ---------- Server → client envelope ---------- + +table APIError { + code: int32; + message: string; +} + +table CatalogList { + catalogs: [Catalog]; +} + +union Message { + CatalogList, + Catalog, + ObjectBatch, + APIError, +} + +table ServerMessage { + msg: Message; + catalog_list: CatalogList; + catalog: Catalog; + object_batch: ObjectBatch; + error: APIError; +} + +root_type ServerMessage; +``` + +## Rationale per decision + +### Columnar `ObjectBatch` vs. per-object `CatalogObject` tables + +| | Per-object tables | Columnar arrays | +|---|---|---| +| Size | ~vtable (≥4-16B) + per-row overhead per object | ~8 bytes per f64, zero per-row metadata | +| Speed | vtable deref per field per object | Linear memcpy / slice iter | +| Odin reads | method call per field per object | `[]f64` direct slice | +| Evolution | per-field evolveable | vector types can't structurally evolve (add new vectors instead) | +| Fit for render loop | ok for thousands | **ideal for 100k+** | + +3-5 columns of raw doubles per row dominate the payload. Columnar wins by a lot. +Rust builds with `create_vector(&flat_array_of_f64)` — trivial. Odin reads with +`ObjectBatch_ra(obj)` → `[]f64`. + +Caveat: in the columnar layout, an individual object is identified by *index* +across columns (`ra[i]`, `dec[i]`, `redshift[i]`). That's fine for bulk +rendering, and `ids[i]` links back to a catalog row when needed. + +### `file_identifier "DESI"` + +- Enables `flatbuffers::size_prefixed_root_with_opts::(opts, buf)` + and (on the Odin side) cheap "is this our message?" pre-checks. +- Without a file identifier, verification accepts buffers that are structurally + valid but a *different* schema — dangerous across stream kinds. + +### `object_count: uint64` — the `Option` presence trap + +Current Rust model has `object_count: Option`. In FlatBuffers: + +- A `uint64` with `default: 0` **is not written when 0** → reader can't tell + "zero" from "absent". If `Some(0)` is meaningful, use an **optional scalar**: + `object_count: uint64 (nullable)`? No — the FBS way is: + +```fbs +table Catalog { + object_count: uint64 (id: 0, default: null); // optional scalar — presence preserved +} +``` + +Optional scalars (`default: null`) are always written when set, and the accessor +returns the language's optional type. Rust: `object_count: Option`; +Odin (hand-rolled): an `is_set` flag. + +Alternatively wrap in a struct or keep as raw `uint64` if you never ship a +meaningful zero. + +### `enum` for object_type + +```fbs +enum ObjectType : byte { + Galaxy = 0, + Quasar = 1, + Star = 2, +} +``` + +- Table fields can be enums; the accessor returns typed enum. +- Keep it a `byte`/`ubyte` — flat buffering stores the underlying integer; a + vector of enums (`object_types: [ObjectType]`) is just a `&[u8]`/`[]byte` view + on the wire (dense — nice for thousands of classifications). + +### Struct `Point` + +- Only correct if a coordinate + type is *structurally fixed forever* (a struct + cannot gain fields). Risky: DESI might add errors/ellipsoid fields later. For + the bulk path prefer the columnar batch (add a new vector). Keep `Point` + struct only if you need tiny random-access records that will never change — + otherwise a `table` is safer for any "object record" shape. + +### Batch sequence number + +- `batch_seq: uint32` lets the client detect drops/reordering/restarts when + paging/straming through a catalog. Server increments per (catalog, region) + session. Cheap insurance on an unordered transport. + +### Union envelop `ServerMessage` + +- Lets **one** WebSocket carry catalog lists, object batches, errors, and + future message kinds, with the type tag built into the buffer (no guessing by + file identifier). +- Rust: generated `msg_type()` + `msg_as_object_batch()` etc. +- Odin: hand-rolled union-tag read + payload access. + +If you'd rather keep streams homogeneous, skip the union and use separate root +types per endpoint (see `05-streaming-protocols.md` Pattern B). + +## Evolution roadmap (defensive) + +- **v1 (this schema)**: as above. +- **v2 (next release)**: append `petro_mag_g/z/r: [float]` (or per-band uint16 + scaled), `spectral_class: [byte]`, `is_spec_selected: [bool]` to `ObjectBatch` + (all **at the end** of the table — v1 decoders ignore them, v2 decoders get + defaults/absent for v1 data). +- **v3**: `Catalog` gains `coverage_footprint: string` (WKT) at the end. +- Do **not** reorder, remove, or deprecate anything unless the schema owner + signs off; run `flatc --conform` in CI. + +## Compatibility with existing Rust/JSON models + +Keep the FlatBuffers schema **in sync** with `api/src/models.rs` (serde) and +`gui/src/data.odin` (mirrors) during the transition: + +- Add a `From for ObjectBatch`-ish conversion inside the API, or + (better) have the API build FlatBuffers **directly from the DESI row fetch**, + bypassing serde for the binary path. +- The JSON endpoints can stay for debugging/curl and for the initial catalog + list (tiny). Document in `api/` that `/api/v1/objects` will switch to + `application/flatbuffer` (or gain a `/json` variant) once the client is wired. + +## Where the schema lives & codegen ownership + +Proposal: a top-level `schema/catalog.fbs` is the single source of truth. + +``` +schema/ + catalog.fbs <- cross-language contract +``` + +- Rust: flatc → `api/src/generated/catalog_generated.rs` +- Odin (Path A): flatcc → `gui/src/generated/catalog_reader.h` (+ bindings) +- Odin (Path B): hand-rolled reader must be tested against fixtures built from + *this* schema (see `08-testing-strategies.md`). + +Add a make target: + +```make +schema-gen: + flatc --rust -o api/src/generated ../schema/catalog.fbs + flatcc --c --common -o gui/src/generated ../schema/catalog.fbs +``` + +and (optionally) a CI check that committed generated files match a fresh +regeneration (`git diff --exit-code` after regen). + +## Open questions to resolve with real DESI data + +1. DESI object IDs: u64 internal, or string (survey-id like "DESI-1234")? + If string, `ids: [string]` (each a mini-table) costs far more than `[uint64]` + — decide based on the actual catalog schema. +2. Coordinate range conventions: ra in `[0,360)` vs `[-180,180)`? Pick once, + document, optionally store a normalized flag in the schema. +3. Redshift scale/precision: double vs float? For rendering, `f32` halves the + payload and is plenty; for science fidelity, keep `f64` as in the model today. +4. Does the renderer need per-object colors today? If yes, precompute a packed + `[uint32]` RGBA vector in the API — better than the client computing per frame. +5. In-stream metadata: do you need a `Metadata` message (region bounds, epoch, + projection) before the first batch? Add now if so — retrofitting a union arm + mid-release is possible but adds churn. \ No newline at end of file diff --git a/resources/ai/research/data-streaming/07-alternatives.md b/resources/ai/research/data-streaming/07-alternatives.md new file mode 100644 index 0000000..cf16a6e --- /dev/null +++ b/resources/ai/research/data-streaming/07-alternatives.md @@ -0,0 +1,150 @@ +# 07 — Alternatives to FlatBuffers + +FlatBuffers isn't the only option. This doc compares the realistic alternatives +for a Rust API → Odin GUI streaming pipeline. The evaluation lens: **cross- +language support (Rust + Odin), zero-copy reads, schema evolution, streaming +fitness, and ecosystem maturity.** + +## Quick comparison table + +| Format | Zero-copy reads | Schema | Wire size (vs JSON) | Serialize | Deser/read | Streaming | Rust support | Odin support | +|---|---|---|---|---|---|---|---|---| +| JSON (current) | No | No | 1.00× (268B) | 5.2 µs | 8.7 µs | SSE/chunk | serde | core:json | +| MessagePack | No | No | 0.60× | 2.1 µs | 3.8 µs | chunk | rmp-serde | tgolsson/msgpack-odin (small) | +| Protocol Buffers | No | **Yes** (proto) | **0.35×** | 1.8 µs | 2.4 µs | gRPC streaming | **prost/tonic** | none official (C via protobuf-c) | +| **FlatBuffers** | **Yes** | **Yes** (fbs) | 0.47× | 0.9 µs | **0.03 µs** | size-prefix/WS | **flatbuffers crate** | none official (C via flatcc) | +| Cap'n Proto | **Yes** | Yes | 0.54× | **0.5 µs** | **0.02 µs** | structured RPC | capnp crate | none official (C via capnproto-c) | +| Apache Arrow IPC | File/stream batch | Yes (FlatBuffers-based) | ~0.42× (typed cols) | batch-append | ~zero-cost column slices | **native stream format** | **arrow crate (official)** | **OdinArrow (community)** | + +(Benchmarks: Mechanical Snail wire-format study, Nov 2025, Rust, 1-MB +median message; 0.47× etc. are compression ratios; absolute numbers vary by +message shape — see `10-performance-benchmarks.md`.) + +## Deeper per-format notes + +### JSON (status quo) + +- **Pros**: human-readable, debug-friendly, universal, no schema burden, + trivially works with curl, streaming via SSE/chunked HTTP. +- **Cons**: slowest (5-14× slower than binary on read-heavy paths), verbose + (2-3× payload size), no type integrity, allocates per-field on parse. +- **In this repo**: already stubbed. Could stay for the *HTTP diagnostics* path, + but for streaming 100k+ points per frame to a renderer, JSON is the wrong tool. +- Verdict: keep for curl/debug endpoints; not the streaming wire. + +### MessagePack + +- **Pros**: binary offshoot of JSON — schema-less, faster, 40% smaller. Rust + (rmp-serde) is mature. There's a small pure-Odin msgpack impl (tgolsson/ + msgpack-odin — timestamps included, no ext types). +- **Cons**: **no schema** → no forward/backward compatibility guarantees, no + zero-copy reads (you parse into Odin structs per field), no type end-to-end + integrity. On reads it's only ~2× faster than JSON — the renderer still pays + a per-field decode. +- Verdict: good "fast JSON" but doesn't solve the zero-copy/schema-evolution + problems. + +### Protocol Buffers (protobuf) + +- **Pros**: smallest wire (varint encoding); gRPC for streaming; **smallest + payloads**; excellent Rust (prost/tonic, prost-build); schema-evolution via + field numbers; huge ecosystem. +- **Cons**: **no zero-copy reads** — deserialization allocates the full object + graph. For 100k objects/frame that's the exact cost FlatBuffers eliminates. + No first-party Odin; C via protobuf-c is clunky. +- Verdict: ideal for "fat service-to-service RPC" where you decode once and + hold objects. Wrong shape for a per-frame high-frequency render workload + unless you pre-decode into the renderer's own buffer. + +### Cap'n Proto + +- **Pros**: true zero-copy (wire layout ≈ memory layout), pointer-based lazy + access, built-in RPC, slightly simpler encode than FlatBuffers (no backward + construction). Serialize ~0.5 µs, deserialize ~0.2 µs (fastest table entries + in the Mechanical Snail study). +- **Cons**: **8-byte alignment makes it ~20-50% larger than FlatBuffers** on + the wire; **no Robert-native Odin**; lower ecosystem/maturity than + FlatBuffers; fewer language bindings (no official WASM/JS story as rich). +- Verdict: a genuinely strong competitor, but for this repo the wiresize penalty + (bandwidth for millions of points) and weaker Odin/JS ecosystem tilt toward + FlatBuffers. + +### Apache Arrow IPC (columnar, used as transport) + +- **Pros**: **the canonical columnar data format**; the *only* alternative with a + **native Odin implementation** (TimeLord/OdinArrow) already shipping a + Zero-copy IPC reader (memory-mapped, pyarrow-interopable). Streaming file/ + stream formats are purpose-built for bulk numeric data. Rust side gets the + official `arrow` crate. +- **Cons**: bigger dependency surface (arrow crate is heavy); schema model is + Arrow's own (Field/RecordBatch) rather than your own; IPC header uses + FlatBuffers internally (Arrow metadata *is* FlatBuffers), which is a point in + FlatBuffers' favor; existing `CatalogObject` models don't map 1:1. +- Verdict: **strongest technical fit for "millions of numeric rows, columnar, + zero-copy, streaming"**. If the DESI dataset is *really* millions-of-points + columnar, Arrow IPC deserves a serious look — but you'd be adopting Arrow's + framing + OdinArrow dependency rather than your own schema/format, which reads + as more risk for a repo this young. + +### Flexbuffers / schemaless (bonus) + +- FlatBuffers ships **Flexbuffers** — its own schemaless, self-describing, + variable-typed format (kind of a fast MessagePack). Rust+JS+Python etc. + supported; Odin via same C ABI story as FlatBuffers. +- Not a fit here: the point of going FlatBuffers is the *shared compiled + schema* keeping Rust/Odin coherent. Flexbuffers gives up exactly that. + +## Decision matrix mapped to DESI Explorer + +| Requirement | FlatBuffers | Cap'n Proto | Protobuf | Arrow | MsgPack | +|---|---|---|---|---|---| +| Zero-copy per-frame reads (100k+ pts) | ✅✅ | ✅✅ | ❌ | ✅✅ | ❌ | +| Compact wire for many f64 columns | ✅ (f64 vectors) | ⚠️ (8B-aligned, larger) | ✅ (varint packed) | ✅✅ (chunked) | ⚠️ | +| Schema evolution (edr→dr1→dr2) | ✅ | ✅ | ✅✅ | ⚠️ (Arrow schema is rigid) | ❌ | +| Rust (API) maturity | ✅ (official crate) | ✅ (capnp crate) | ✅✅ (prost/tonic) | ✅✅ (official) | ✅ | +| Odin (GUI) availability | ⚠️ C-ABI/flatcc or hand-rolled | ⚠️ C-ABI or hand-rolled | ❌ (protobuf-c barely) | ✅ (OdinArrow) | ⚠️ (small impl) | +| WASM/JS story | ✅ (official npm) | ⚠️ | ✅ | ⚠️ | ✅ | +| Streaming primitives | ⚠️ (BYO framing; flatstream-rs exists) | ✅ (RPC) | ✅ (gRPC) | ✅✅ (IPC stream) | ⚠️ (chunking only) | + +## Bottom line + +- **FlatBuffers is the best default** for this project: the zero-copy read model + matches the render loop, the wire format is compact for numeric vectors, schema + evolution fits DESI's release cadence, and the Rust + WASM/JS official story + is strong. The Odin gap is real but solvable (C-ABI via flatcc, hand-rolled + reader, or OdinArrow patterns). +- **Apache Arrow IPC is the strongest alternative** if the data is truly + "columnar millions-of-rows" and you accept OdinArrow + the Arrow header/ + framing dependency. It even uses FlatBuffers internally for its metadata, so + it's not an either/or at the format level. +- **Protobuf/gRPC** wins only if you'd rather have official Rust streaming + + smallest payloads and are OK decoding into objects once (not per frame) — the + renderer then builds its own draw buffers. For 100k objects/frame the + "decode-once-into-draw-buffer" model is actually defensible; measure before + over-engineering. + +## Test-before-commit suggestion + +Run a micro-benchmark (criterion) on your **actual** payload shape (e.g. 10k +objects × 3 f64 columns / batch) comparing: +1. serde JSON → parse +2. FlatBuffers columnar batch (this proposal) +3. protobuf (prost) → decode to Vec +4. Arrow IPC record batch (arrow crate) → record batch slices + +Numbers will decide the finer trade-offs; the qualitative table above is the +strategic direction. + +## References + +- Mechanical Snail, "Wire Formats for High-Volume Service Communication" (2025-11): + https://mechanicalsnail.com/posts/wire-formats-rust/ +- Youngju, "Binary Serialization Complete Guide" (2025-2026): + https://www.youngju.dev/blog/...-binary-serialization-... +- Maltsev & others, "Impact of Serialization Format on Inter-Service Latency" + (2024): https://science.lpnu.ua/sites/default/files/journal-paper/2024/dec/... +- Opentraffic/DEEPWIKI Flatbuffers deep dive: + https://deepwiki.com/opentraffic/flatbuffers +- Arrow IPC / OdinArrow: + https://arrow.apache.org/docs/format/Columnar.html + https://github.com/TimeLord/OdinArrow +- msgpack-odin: https://github.com/tgolsson/msgpack-odin \ No newline at end of file diff --git a/resources/ai/research/data-streaming/08-testing-strategies.md b/resources/ai/research/data-streaming/08-testing-strategies.md new file mode 100644 index 0000000..2aaf26b --- /dev/null +++ b/resources/ai/research/data-streaming/08-testing-strategies.md @@ -0,0 +1,240 @@ +# 08 — Unit & Integration Testing Strategies + +FlatBuffers introduces a **cross-language wire contract**. The single most +important testing principle: **both sides must prove they talk the identical +bytes**, not just that each side round-trips with itself. + +## Testing layers + +### 0. Schema hygiene (fastest feedback) + +- **Schema lint / conformance**: run `flatc --conform base.fbs candidate.fbs` + in CI to catch accidental schema-evolution violations (fields added in the + middle, removals, type changes). Early warning before any of the below. +- **Commit-and-diff**: check generated code (`.rs`, `.h`) into the repo; CI + re-runs codegen and `git diff --exit-code`s — catches "you forgot to + regenerate" drift. + +### 1. Rust-side unit tests (API crate) + +The `flatbuffers` crate's own tests live in +`tests/rust_usage_test/tests/integration_test.rs` — copy the patterns: + +**Round-trip test** (write → read own buffer): + +```rust +#[cfg(test)] +mod object_batch_tests { + use crate::generated::desi::{ObjectBatch, ObjectBatchArgs, finish_object_batch_buffer, root_as_object_batch}; + use flatbuffers::FlatBufferBuilder; + + fn build_sample_batch() -> Vec { + let mut fbb = FlatBufferBuilder::with_capacity(4096); + let ra = fbb.create_vector(&[0.1, 25.3, 99.9]); + let dec = fbb.create_vector(&[-5.0, 44.0, -77.2]); + let z = fbb.create_vector(&[0.5, 1.2, 2.8]); + let ids = fbb.create_vector(&[1u64, 2, 3]); + let batch = ObjectBatch::create( + &mut fbb, &ObjectBatchArgs { + catalog: Some(fbb.create_string("dr1")), + batch_seq: 0, n: 3, + ids: Some(ids), ra: Some(ra), dec: Some(dec), redshift: Some(z), + ..Default::default() + }); + finish_object_batch_buffer(&mut fbb, batch); + fbb.finished_data().to_vec() + } + + #[test] + fn roundtrip_columnar_batch() { + let bytes = build_sample_batch(); + let batch = root_as_object_batch(&bytes).unwrap(); + assert_eq!(batch.catalog(), Some("dr1")); + assert_eq!(batch.n(), 3); + assert_eq!(batch.ra().unwrap()[1], 25.3); + assert_eq!(batch.dec().unwrap()[2], -77.2); + assert_eq!(batch.redshift().unwrap()[0], 0.5); + } + + #[test] + fn verifier_rejects_corruption() { + let bytes = build_sample_batch(); + for idx in [0usize, 1, bytes.len() - 1] { + let mut bad = bytes.clone(); + bad[idx] ^= 0xFF; + assert!(root_as_object_batch(&bad).is_err(), "byte mutate at {idx} should fail verification"); + } + } +} +``` + +Key Rust unit-test patterns: +- Values equal **defaults are not serialized** → test that explicitly: + `assert_eq!(batch.missing_field_u64(), 0)` when field absent, and that an + explicitly-set 0 is indistinguishable (unless optional scalar). Cf. the + `optional_scalars_test.rs` macro in the flatbuffers repo. +- **Alignment tests**: assert struct/table accessor pointers are properly + aligned relative to buffer start (the flatbuffers repo has + `generated_code_alignment_and_padding` tests — replicate for `Point`/ + columnar slices). +- **Fuzz** (proptest/quickcheck): roundtrip random vectors of `f64`/`u64` + through the builder and back (fb's `roundtrip_vectors` uses quickcheck). Add + property tests: for any `&[f64]` built with `create_vector`, `ra().unwrap()` + equals the input exactly. + +### 2. Rust-side integration tests (HTTP/WS layer) + +The repo already has `api/tests/health.rs` (spins the app against real routes +via axum). Add an `api/tests/catalog_fb.rs`: + +```rust +// Uses the same axum::Router::app() as health.rs, hits the new WS or +// application/flatbuffer endpoint with tower::ServiceExt::oneshot(), +// collects the body Bytes, and verifies + reads it with the generated types. +#[tokio::test] +async fn objects_endpoint_returns_valid_flatbuffers() { + let app = desi_explorer_api::app(); // or routes::app() + let resp = app.oneshot(Request::builder().uri("/api/v1/objects?catalog=dr1&limit=100").body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let batch = crate::generated::desi::root_as_object_batch(&body).unwrap(); + assert_eq!(batch.n(), 100); + assert_eq!(batch.ra().unwrap().len(), 100); +} +``` + +For WebSocket integration, spawn the server on `127.0.0.1:0` in a test (tokio) +and use `tungstenite`/`tokio-tungstenite` as a test client, or call the endpoint +through axum's mock. At minimum: open a WS, receive N binary frames, verify each +frame parses + verifies + row counts match expectations. + +### 3. Odin-side tests (GUI crate) + +`odin test` exists in this repo (`make test` → `odin test` in gui/). Structure: +- **Reader unit tests** in the Odin package: given a hard-coded byte blob (from + a fixture file), `verify` and read expected field values. +- **Round-trip** against a builder written in Odin (if you hand-roll a builder + for Path B) — mirror the Rust patterns. + +```odin +// gui/src_tests/catalog_fb_test.odin (whatever path `odin test` picks up) +package catalog_fb + +import "core:testing" + +@(test) +test_read_static_batch :: proc(t: ^testing.T) { + data := #load("../testdata/catalog_sample.bin", []u8) + // Read root, iterate ra/dec/redshift via the reader, assert values. + batch := root_as_object_batch(data) + testing.expect(t, batch.n == 3) + testing.expect(t, batch.ra[1] == 25.3) + testing.expect(t, batch.dec[2] == -77.2) + testing.expect(t, batch.redshift[0] == 0.5) +} +``` + +The **critical** test here is the one reading a fixture *produced by the Rust +side / flatc* — see the "cross-language fixture" section below. + +### 4. Cross-language conformance suite (THE key integration test) + +This is the test that actually catches incompatibility. Design: + +1. **Static fixtures, committed to the repo** (`testdata/*.bin`): + - Built once by `flatc --binary .fbs .json` (deterministic, + reproducible in CI with the pinned `flatc`), or by the Rust builder in a + small crate that writes testdata. + - Cover every field type: catalogs, list, empty batch, batch with defaults + omitted, optional scalars present/absent, enum variants, nested strings. +2. **Rust reads fixtures** + asserts expected values. +3. **Odin reads the same fixtures** + asserts identical expected values. +4. **Both sides parse each other's output**: a Rust test writes a buffer and a + companion Odin test reads it (and vice versa). Practically: Rust generates a + batch during `cargo test`, dumps to `OUT_DIR`, Odin test loads it via + `#load` — but static committed fixtures are simpler and just as strong for + the schema-versioned contract. +5. **Byte-for-byte equality on builders**: for deterministic input, the Rust + builder output should byte-identical to flatc's. Assert on fixtures. + +Generate fixture data from a **JSON descriptor** in the repo +(`testdata/catalog_sample.json`) via: + +```bash +flatc --binary --schema schema/catalog.fbs -- testdata/catalog_sample.json +``` + +Committed `.bin` files mean: no toolchain needed to run the test suite; CI can +regenerate and diff to catch drift. + +**A pure-Odin reader on Path B lives or dies by this suite** — it's the only +guarantee your hand-rolled offset/jtable/vtable arithmetic matches Google's. + +### 5. Transport-level integration (end-to-end) + +Two-app test: run the **real** axum server with the **real** DESI fixture data, +drive it from an **Odin test binary** (or a Rust test client for parity): + +```rust +// api/tests/ws_stream.rs — spawn server on ephemeral port, connect WS +// as the client would, assert: schema message first, then N batches, +// then EOS; each batch verifies and cumulative n == expected. +``` + +This closes the loop on the exact runtime path (frame boundaries, file +identifier, union tags) that unit tests miss. + +### 6. Property / fuzz testing + +- **Rust**: `proptest` generating arbitrary `&[f64]`/`Vec` row sets → + build batch → read → compare; plus fuzz the *verifier* with mutated buffers + (the flatbuffers repo does a one-byte-corruption sweep on its own tests — + replicate: for many positions, flip byte, assert verifier either accepts + *correctly* (data still consistent) or rejects; never panics / OOB). +- **Odin**: property tests are harder (no built-in fuzzer); rely on the Rust + side for mutation fuzzing and keep Odin tests as fixed-fixture + round-trip + sanity checks. +- **Corrupt-length attack**: a stream with `length = 0xFFFFFFFF` must be + rejected at framing, not OOM. Test the length-prefix path explicitly + (size prefix + WebSocket frame-size caps). + +### 7. CI wiring in this repo + +`.gitea/workflows/ci.yml` currently: `odin test` + build, `cargo test` + +clippy, `go test` + vet. + +Additions when FlatBuffers lands: +- A **shared schema lane**: `flatc --conform` check + `git diff --exit-code` + after regen (catches schema drift). +- Rust lane: compile generated code included from `api/src/generated`, run new + unit/integration tests (no extra CI deps if generated code is committed). +- Odin lane: run the conformance tests reading committed `.bin` fixtures (no + extra CI deps). +- Optional: the end-to-end WS test (server + client) in the cargo lane. + +Keep `make test` as the single gate: `make test` = odin test + cargo test + +go test, and each now includes the FlatBuffers conformance pieces. + +## Testing checklist (implementation-ready) + +- [ ] `flatc --conform` ran against schema history in CI +- [ ] Committed generated code matches a fresh regen (CI diff check) +- [ ] Rust: round-trip builder↔reader unit tests (all field types / enum / + optional scalar / default-omission cases) +- [ ] Rust: verifier-safety & corruption fuzz +- [ ] Rust: HTTP + WS integration tests against live router +- [ ] Odin: reader unit tests on static fixtures +- [ ] Cross-language: Rust reads fixtures ✓; Odin reads same fixtures ✓; + byte-for-byte parity on builder output +- [ ] End-to-end: real server + real client (WS) with DESI-shaped rows +- [ ] Length/frame-stealing attack tests (huge length, truncated frames) +- [ ] No regression to existing JSON health/catalogs tests during migration + +## References + +- flatbuffers Rust test suite (patterns to copy): + https://github.com/google/flatbuffers/blob/master/tests/rust_usage_test/tests/integration_test.rs +- optional_scalars_test.rs (default-omission semantics): + https://github.com/google/flatbuffers/blob/master/tests/rust_usage_test/tests/optional_scalars_test.rs +- `flatc --conform` docs: https://flatbuffers.dev/evolution/ +- Existing repo test conventions: `api/tests/health.rs`, `make test`, `.gitea/workflows/ci.yml` \ No newline at end of file diff --git a/resources/ai/research/data-streaming/09-pain-points.md b/resources/ai/research/data-streaming/09-pain-points.md new file mode 100644 index 0000000..4ab4023 --- /dev/null +++ b/resources/ai/research/data-streaming/09-pain-points.md @@ -0,0 +1,199 @@ +# 09 — Cons, Gotchas, & Pain Points + +A candid list of everything that will hurt if you adopt FlatBuffers for this +project — and how to mitigate each. + +## 1. No first-party Odin binding (the biggest risk) + +- FlatBuffers officially supports many languages; **Odin is not one**. The Odin + side requires either (a) C ABI FFI to the third-party **FlatCC** runtime, (b) + a **hand-rolled pure-Odin reader** (100-300 lines, plus maintenance forever), + or (c) reuse of **OdinArrow's** hand-rolled FlatBuffers decoder. +- **Impact**: schema changes ripple into *your* Odin code, not generated code. + Update discipline + the conformance suite are mandatory, not optional. +- **Mitigation**: commit static `.bin` fixtures and hard-code the conformance + tests; keep the schema churn low; consider a tiny code generator if the + schema grows fast. + +## 2. Serialization is harder than JSON/protobuf (back-to-front building) + +- Objects are built **children-before-parents**: create all strings/vectors/ + nested tables first, then the parent that references them. FlatCC's builder + is oriented differently (start/end + stack) and also non-obvious. +- Error-prone ordering bugs are caught only by round-trip tests. +- **Impact**: server-side code is more verbose; onboarding cost. +- **Mitigation**: wrap builder logic in small helper functions per message type; + comprehensive round-trip tests; the columnar layout keeps touching-code + minimal (just `create_vector` × columns). + +## 3. Larger on the wire than protobuf + +- vtables add 4-16 bytes per table; scalar vectors are NOT varint-packed + (every f64 is 8 bytes regardless). FlatBuffers is ~20-50% larger than + protobuf on comparable payloads; in the Go benchmark, proto 82B vs FB 192B on + a small object. +- **Impact**: for millions of objects over a constrained link this is real + bandwidth. For a LAN/k3s homelab renderer, negligible. +- **Mitigation**: columnar vectors eliminate per-row vtable overhead; omit + default-valued fields (free savings); compress with gzip/zstd at the + transport (WebSocket payloads are compressible — but you lose zero-copy if + you decompress into another buffer; keep compressed frames bounded). + +## 4. Not self-describing / schema coupling + +- A FlatBuffer is meaningless without its `.fbs`. Clients (and your future + self debugging with `curl`) cannot inspect a buffer without the schema. +- Schema must be **shared and version-disciplined** between api/ and gui/ — + exactly the drift problem that `data.odin`'s JSON mirrors already suffer. +- **Impact**: debugging is harder; schema changes are coordinated releases. +- **Mitigation**: file identifiers (`file_identifier "DESI"`) for self-checking; + generate JSON from buffers (`flatc --json --schema foo.fbs -- foo.bin`) for + debugging/inspection; keep a `schema/` in-repo single source of truth; + `flatc --conform` in CI. + +## 5. Immutable buffers / no in-place mutation + +- Once built, you cannot edit a FlatBuffer in place (the old C++ JSON path had + reflective mutation; Rust's reflection/resizing is **experimental**). "Fix a + field" → rebuild the whole buffer. +- For the renderer this is fine (it's read-only). For the **server** it means + any per-client customization (filtering, projections) happens *before* build, + or you build per-client variants (expensive). +- **Mitigation**: build canonical buffers; push client-side filtering to the + client (it's zero-copy cheap for the client to skip rows). + +## 6. 2 GiB buffer ceiling + +- Single FlatBuffer cannot exceed 2 GiB (`u32` offsets). A full DESI survey is + millions of rows × ~24-40 bytes = gigabytes. You **must batch**. +- **Impact**: streaming design becomes mandatory (fine — it's the plan). +- **Mitigation**: batches of ≤ 1-4 MB; `batch_seq` to reassemble/order; client + holds N buffers, not one gigantic one. + +## 7. Verification is required for untrusted input + +- Without verification, field reads on a malformed buffer → OOB (Rust: panic / + UB with `_unchecked`; Odin: guaranteed memory unsafety). Your data comes from + **a network** — verify. +- Rust's verifier is good but "experimental" historically: configurable + `VerifierOptions` (max_depth/max_tables) help against DoS via deep buffers. +- **Impact**: an extra O(n) pass per frame. For a 4 MB batch this is sub-ms — + acceptable; still, do it **once per batch**, not per accessor. +- **Mitigation**: verify on ingest for untrusted streams; for *trusted* replay + (local fixtures) you may use `_unchecked` for the benchmark path; add + `max_*` caps. + +## 8. Cross-build/toolchain friction + +- flatc/flatcc development video: version pinning matters — generated code must + match the runtime crate version. Mismatched `flatc` ↔ `flatbuffers` crate → + subtle incompatibilities. +- The repo pins Odin version and Rust version; now you pin **flatbuffers too** + (schema → generator → runtime all consistent), plus (Path A) compile flatcc + under Emscripten for WASM. +- **Impact**: build-system surface area grows. +- **Mitigation**: commit generated code (skip codegen in CI); pin exact crate/ + tool versions in `api/Cargo.toml`, gui Makefile, and CI YAML; centralize flatc + install (scripts/ like `install_odin.sh` pattern). + +## 9. WASM/Emscripten path is the fuzziest area + +- Zero-copy across the JS↔WASM boundary doesn't exist; JS decodes FlatBuffers + (official lib) then hands typed arrays into WASM heap (one memcpy) — you've + given up the headline feature for the web build. +- Or the Odin WASM code reads frames delivered by JS; but then JS→WASM handoff + doubles as a copy anyway (unless you use `WebAssembly.Memory` shared views and + careful ownership). +- **Impact**: web build behaves *worse* than native (memcpy + JS decode), which + is expected but worth stating. Native desktop (`make run`) is unaffected. +- **Mitigation**: accept one memcpy for web; keep the native zero-copy path + clean; test both. + +## 10. Odin-type ergonomics: optional scalars, enums, slices + +- Odin has no `Option`/`Result` sugar. Optional scalars (`default: null`) + surface as "present? + value" pairs you hand-roll: an `is_set bool` flag (or + NaN sentinel for f64) alongside `value: f64`. +- Enums are fine (Odin `enum` with explicit values) but vector-of-enum reads as + `[]byte` with manual cast. +- Reading column vectors → `[^]f64`/`[]f64` requires the **buffer outlive the + view**: zero-copy slicing borrows the frame buffer. Freeing the frame before + the render pass = dangling `[]f64`. Ownership discipline (defer-free) needed. +- **Mitigation**: document the "frame buffer must outlive render pass" rule; + write copy-free helper procs that take the buffer explicitly; test with + over-release patterns. + +## 11. Debugging binary data + +- No `curl | jq`. Binary payloads are opaque without tooling: `flatc --json` + (official) and flatcc's JSON printer (per-schema C) are your tools. +- IDE display of buffers is limited; hex dumps are the fallback. +- **Impact**: slower onboarding for new contributors; harder support/debug + sessions. +- **Mitigation**: keep the JSON endpoints (health/catalogs) for humans; add a + tiny dev CLI (or make target) that dumps a `.bin` to JSON via flatc; log + length/hash per frame instead of contents. + +## 12. Ecosystem / library maturity wrinkles + +- Rust flatbuffers crate is labeled **"experimental"** in docs although widely + used; API churn across minor versions historically (see docs.rs). Pin + precisely. +- `flatbuffers-build` uses a **symlink-in-src** hack to surface generated code + to build.rs — a smell that can break with Cargo changes. Committing generated + code avoids it (recommended here). +- No official Odin, no official C# for web-unity-esque paths, no protobuf-style + RFC coverage. Small but real gaps. + +## 13. Opinionated schema discipline + +- Teams that skip evolution rules (reordering, deleting, default changes) get + silent data corruption — *worse* than an explicit break because reads don't + error, they return wrong values. +- Rule: new fields only at table end; deprecate (don't delete); don't change + defaults; enums only grow; structs are frozen forever. Enforce with + `flatc --conform` + review discipline. + +## 14. Odin ecosystem: borrowing from OdinArrow is a dependency decision + +- OdinArrow is community-maintained (TimeLord). If you extract only its + FlatBuffers decoder, you own a fork; if you depend on it wholesale, you hold + a third-party Odin dependency in `gui/lib/` with its own update cadence. +- License + maintenance verification required before adoption (the repo's + `add-dep` flow exists for exactly this: submodule into `gui/lib/local/`). + +## 15. Performance expectation management + +- Benchmarks are message-shape-dependent. FlatBuffers' deserialization reads + are near-zero, but **serialization is not fastest** (Mechanical Snail: FB + serialize 0.9µs vs Cap'n 0.5µs). The distributed-latency study even found + protobuf/avro/thrift beat FlatBuffers on end-to-end latency in some + microservice topologies because **payload size dominates network time**. +- For LAN renderer workloads, FlatBuffers' win is real (read-side) but don't + over-promise on shrink: it's bigger than protobuf. +- Measure with your own criterion bench before/after (see `07-alternatives.md` + suggestion). + +## Pain-point severity ranking + +| # | Pain | Severity | Mitigation exists? | +|---|---|---|---| +| 1 | No Odin binding (FFI/hand-roll) | High | Yes (flatcc / OdinArrow / conformance) | +| 2 | Back-to-front serialization complexity | Medium | Yes (helpers + tests) | +| 3 | Bigger wire than proto | Low-Medium | Yes (columnar + omit defaults) | +| 4 | Not self-describing | Medium | Yes (fixture files + file id) | +| 5 | Immutability | Low | Yes (rebuild per batch) | +| 6 | 2GiB ceiling → batching | Low | Yes (batch streaming) | +| 7 | Verifier needed for untrusted input | Medium | Yes (verify once per batch) | +| 8 | Toolchain/version pinning | Medium | Yes (commit generated, pin versions) | +| 9 | WASM zero-copy loss | Low (web is secondary) | Yes (accept 1 memcpy) | +| 10 | Odin ergonomics (Option/slices/borrow) | Medium | Yes (patterns + tests) | +| 11 | Debugging binary | Low | Yes (flatc --json + keep JSON endpoints) | +| 12 | Library maturity wrinkles | Low | Yes (pin, commit generated) | +| 13 | Schema discipline | Medium | Yes (flatc --conform in CI) | +| 14 | OdinArrow dependency | Low | Yes (license/maintenance review) | +| 15 | Performance expectation | Low | Yes (own benchmarks) | + +**Overall**: all pain points have identified mitigations; the two that require +active, ongoing investment are **(1) the Odin reader** and **(13) schema +discipline**. Budget for them in the implementation plan. \ No newline at end of file diff --git a/resources/ai/research/data-streaming/10-performance-benchmarks.md b/resources/ai/research/data-streaming/10-performance-benchmarks.md new file mode 100644 index 0000000..18cb2c0 --- /dev/null +++ b/resources/ai/research/data-streaming/10-performance-benchmarks.md @@ -0,0 +1,147 @@ +# 10 — Performance Benchmarks (published data) + +Numbers vary a lot by benchmark harness, message shape, and library version. +This doc collects representative published results, normalizes what can be +normalized, and interprets them **for the DESI Explorer use case** (streaming +bulk `ra`/`dec`/`redshift` columns to a renderer). + +## Summary table (large/mixed messages, Rust, per-op medians) + +From Mechanical Snail's Rust wire-format study (Nov 2025, c6i.4xlarge, +UserProfile sample ~280B JSON; 1M cycles; versions noted in `03`): + +| Format | Serialize (µs) | Deserialize (µs) | Size (bytes) | Rel. size | Notes | +|---|---|---|---|---|---| +| JSON (serde_json) | 5.2 | 8.7 | 280 | 1.00× | baseline | +| MessagePack (rmp_serde) | 2.1 | 3.8 | 168 | 0.60× | schema-less | +| Protobuf (prost) | 1.8 | 2.4 | 98 | 0.35× | smallest | +| **FlatBuffers** | **0.9** | **0.3*** | 132 | 0.47× | *single-field access, zero-copy | +| Cap'n Proto (capnp) | **0.5** | **0.2*** | 152 | 0.54× | *single-field access, zero-copy | +| Avro | 3.1 | 4.5 | 105 | 0.38× | | + +> *For zero-copy formats, "deserialize" here means *accessing a single field* +> without traversal. Full traversal ≈ 2-3 µs for both — still ~3-30× faster +> than decoding JSON to objects. + +Key takeaways: +- **Undisputed**: binary formats are 2-14× faster than JSON; zero-copy formats + are the fastest on the read path. +- **Size leader**: protobuf (varint). FlatBuffers and Cap'n Proto are 30-55% + larger than proto at this message shape. +- **Write leader**: Cap'n (+40% vs FlatBuffers at this shape). Read leader: + Cap'n/FB both near-free for single field. + +## Second benchmark (Go, small objects, `format-wars.go`) + +| Format | Marshal ns/op | Unmarshal ns/op | Size bytes | Allocs (marshal) | +|---|---|---|---|---| +| MessagePack (msgp) | 69-205 | 155-336 | 157-323 | 1 | +| **FlatBuffers** | **183-414** | **192-450** | 192-448 | **0** | +| Protobuf | 156-1033 | 315-1173 | 82-276 | 1-21 | +| CBOR | 253-453 | 751-1056 | 141-324 | 1-3 | +| JSON (std/v2) | 463-946 | 842-2819 | 213-1940K | 1-3+ | + +Highlights: **FlatBuffers is the only format with 0 allocations on marshal** — +its CSR for the renderer (no per-frame GC/allocation churn) is genuinely +unique among these. + +## Third benchmark (distributed latency, Lviv Polytechnic 2024/2025) + +- **Serialize speed**: FlatBuffers fastest across all message sizes (med <2µs; + vs proto 2.8-27µs). +- **Deserialize speed**: FlatBuffers and Cap'n (unpacked) overwhelming leaders + (FlatBuffers med 0.02-0.03µs). +- **End-to-end distributed latency**: Avro/Protobuf/Thrift beat FlatBuffers + (med reductions vs JSON: Avro -84%, Proto -82%, Thrift -80%, Cap'n packed + -71%, **FlatBuffers only -17%**). Reason: **payload size dominates** in + distributed hops; FlatBuffers isn't the most compact. +- **Interpretation**: if your bottleneck is *network* (large hops over WAN), + compact formats (Avro/proto) win end-to-end. If your bottleneck is + *CPU/allocation on massive reads* (a renderer ingesting the same bytes + repeatedly), zero-copy formats win. DESI Explorer is the latter. + +## Fourth benchmark (C++, embedded/small messages, CppSerialization 2025) + +| Protocol | Message size | Serialize | Deserialize | +|---|---|---|---| +| SBE | 138 B | 35 ns | 52 ns | +| zpp::bits | 130 B | 34 ns | 37 ns | +| Cap'n Proto | 208 B | 247 ns | 184 ns | +| FlatBuffers | 280 B | 272 ns | **81 ns** | +| Protobuf | 120 B | 322 ns | 351 ns | +| JSON | 301 B | 696 ns | 291 ns | + +## What these numbers mean for DESI Explorer specifically + +### Reads are the whole point + +The renderer will read every point **every frame** (projection + color). Even +at a modest 60 fps with 100k visible objects: + +| Approach | Per-object read cost (approx) | Per-frame cost @100k | +|---|---|---| +| JSON parse (serde_json to objects) | ~87 ns (1ms/100k) | ~8.7 ms — 52% of a 16.7ms frame | +| protobuf decode to objects | ~24 ns | ~2.4 ms — 14% | +| FlatBuffers (safe_slice into `&[f64]` columns) | ~0.3 ns (slice view) | **~0.03 ms — 0.2%** | + +The gap is a **100×** per-frame savings against JSON — the difference between +"render a 100k+ galaxy view comfortably" and "frame budget eaten by parsing." + +- The reason the win is so large for your workload: **bulk reads of the same + buffer reconstituted each frame**. Allocating + copying 100k objects per + frame is what you're avoiding. +- If instead you decode protobuf **once** into a renderer-owned + `[]rl.Vector3` buffer (not per frame), protobuf becomes competitive — you're + paying allocation once, not per frame. That's the strongest counter-case to + FlatBuffers, and worth benchmarking before you commit (see below). + +### Writes (server side) are a non-issue + +Server serialization of a 50k-object batch, even at FlatBuffers' ~1-3µs/MB +shape, is negligible next to DB query + network. If it ever matters, reuse the +`FlatBufferBuilder` (with `reset()`) and use `with_internal_capacity` to avoid +realloc spikes. FlatBuffers' *builder* is not the fastest writer, but writers +are amortized; **readers are per-frame**. Optimize where the frame is. + +### Wire size tradeoff is acceptable + +On ~24 MB of raw f64 columns per 100k objects, FlatBuffers columnar adds only +vtables per batch (≈ tens of bytes), not per row — dropping closer to the raw +size than the "132 vs 98 vs proto" small-object ratio suggests. Columnar **is** +where the size concern basically evaporates. + +Bandwidth-wise: LAN/k3s homelab is not bandwidth-constrained; a 3 MB batch +arrives in ~ms. + +## Recommended local benchmark (before committing) + +Write a criterion benchmark on *your* exact shape: + +1. 100k rows × columns `ra/dec/redshift: f64` (+optional `ids: u64`). +2. Variants: + - serde JSON (current contract) → parse + map to Vec + - FlatBuffers **columnar** batch → `safe_slice` reads + - protobuf (prost) → decode to Vec + - Arrow IPC record batch → batch slices +3. Metric: **per-frame read cost** (60fps frame budget) + wire bytes + first-batch + latency. +4. Also measure: build time (server), verify time at ingest, delta vs `_unchecked`. + +Reality check: the numbers here point to FlatBuffers (or Arrow IPC) winning the +*frame-cost* metric decisively; protobuf wins the *wire-size* metric; JSON wins +only developer ergonomics. Decide based on what frame cost is worth to you. + +## Sources + +- Mechanical Snail, "Wire Formats for High-Volume Service Communication" (2025-11-24): + https://mechanicalsnail.com/posts/wire-formats-rust/ +- kapetan-io/format-wars.go (Go, small objects, allocs): + https://github.com/kapetan-io/format-wars.go +- Maltsev et al., "Impact of Serialization Format on Inter-Service Latency" + (ACPS 2024): https://science.lpnu.ua/sites/default/files/journal-paper/2024/dec/36976/... +- chronoxor/CppSerialization (C++, embedded/micro): + https://github.com/chronoxor/CppSerialization +- Shekhar Manna, "Binary Serialization Formats — A Technical Benchmark & Decision Guide" (2026-02): + https://medium.com/@shekhar.manna83/binary-serialization-formats-e2703f053010 +- ADHDecode, "Protobuf vs FlatBuffers vs Cap'n Proto" (2026-03): + https://adhdecode.com/api-architecture/grpc-deep-dive/protobuf-vs-flatbuffers-vs-capn-proto/ \ No newline at end of file diff --git a/resources/ai/research/data-streaming/README.md b/resources/ai/research/data-streaming/README.md new file mode 100644 index 0000000..79848d7 --- /dev/null +++ b/resources/ai/research/data-streaming/README.md @@ -0,0 +1,85 @@ +# Data Streaming Research: FlatBuffers for DESI Explorer + +This directory aggregates research on using **FlatBuffers** as the wire protocol +between the Rust/axum API (`api/`) and the Odin + raylib GUI (`gui/`). The goal +is to provide implementation-ready knowledge — not to build anything yet. + +## Context + +DESI Explorer currently serves placeholder JSON from a Rust/axum API and renders +procedurally generated points in an Odin + raylib client. The two halves are +**disconnected**: the API has stubbed `/api/v1/catalogs` and `/api/v1/objects` +endpoints returning JSON; the GUI has hand-written mirror structs in +`gui/src/data.odin` with stub `get_catalogs()` / `get_catalog_objects()` +procedures. Real DESI catalog data (galaxies, quasars, stars with `ra`, `dec`, +`redshift`) will eventually stream server → client. + +FlatBuffers is attractive here because: +- **Zero-copy reads**: the GUI can access fields directly from the network buffer + with no parsing or allocation — critical when rendering tens of thousands of + points per frame. +- **Schema-versioned**: forward/backward compatibility as the DESI catalog + schema evolves across releases (edr → dr1 → dr2 → ...). +- **Cross-language**: Rust and Odin can both generate code from the same `.fbs` + schema. +- **Compact binary**: smaller payloads than JSON over the wire. + +## Research Documents + +| # | Document | Purpose | +|---|----------|---------| +| 01 | [architecture.md](01-architecture.md) | Current system architecture and where FlatBuffers fits in | +| 02 | [flatbuffers-overview.md](02-flatbuffers-overview.md) | What FlatBuffers is, how the format works internally | +| 03 | [rust-integration.md](03-rust-integration.md) | Rust/axum API integration: crates, build tooling, codegen pipelines | +| 04 | [odin-client-integration.md](04-odin-client-integration.md) | Odin GUI integration: FFI, flatcc, OdinArrow, HTTP/WebSocket clients | +| 05 | [streaming-protocols.md](05-streaming-protocols.md) | WebSocket + HTTP + framing options for streaming FlatBuffers | +| 06 | [schema-design.md](06-schema-design.md) | DESI-specific schema proposal and evolution rules | +| 07 | [alternatives.md](07-alternatives.md) | Cap'n Proto, Protocol Buffers, MessagePack, Apache Arrow comparison | +| 08 | [testing-strategies.md](08-testing-strategies.md) | Unit, integration, cross-language, fuzz, and conformance testing | +| 09 | [pain-points.md](09-pain-points.md) | Cons, gotchas, and pain points to watch out for | +| 10 | [performance-benchmarks.md](10-performance-benchmarks.md) | Published benchmarks (serialize/deserialize/size) and analysis | + +## TL;DR Recommendation + +**FlatBuffers is a strong fit** for the DESI Explorer streaming use case, +specifically for the *server → client* bulk data path (catalog objects). The +zero-copy read model matches the render loop perfectly: the GUI ingests a binary +blob over WebSocket or HTTP, verifies it once, and reads `ra`/`dec`/`redshift` +directly from the buffer each frame without allocations. + +**Key caveats to weigh before committing:** + +1. **Odin has no first-party FlatBuffers binding.** You must go through the + C ABI (via `flatcc` headers + Odin `foreign` blocks) or hand-roll a minimal + reader. [OdinArrow](https://github.com/TimeLord/OdinArrow) already hand-rolls + a FlatBuffers encoder/decoder for the Arrow IPC header — proof the pattern is + viable in pure Odin. +2. **Serialization is more complex than JSON/protobuf.** The builder API builds + buffers back-to-front (children before parents). This is a server-side cost + you pay once per batch, not per client — acceptable. +3. **Not self-describing.** Binary buffers are opaque without the schema. The + API and GUI must share the same `.fbs` file and version discipline. Add file + identifiers and keep both sides in lockstep via a shared schema checkout. +4. **FlatBuffers is larger on the wire than protobuf** (estimated 20-50% larger + on small messages due to vtable overhead), but ~30-100x faster on reads. For + a read-heavy renderer this trade is worth it. +5. **WASM considerations.** The Web GUI build (Emscripten/WASM) uses raylib via + Odin. If FlatBuffers keep-alive buffers share memory between the Odin side and + the JS/WebSocket glue, you need to manage Emscripten memory carefully. This + is the least-researched area of this report. + +## Immediate Next Steps (when you're ready to implement) + +1. **Prototype schema first.** Write `catalog.fbs` covering `Catalog`, `CatalogObject`, + and a `ServerMessage` union (handshake / catalog list / chunk of objects / end). +2. **Generate Rust code** via `flatc --rust` in an `api/build.rs` (see + `03-rust-integration.md`) and serve over WebSocket using axum's `ws` module + (tower-http CORS + `axum::extract::ws`). +3. **Prototype the Odin reader.** Pick one of three paths (FFI to `flatcc`, + pure-Odin reader port, or OdinArrow reuse) and read a FlatBuffer produced by + the Rust side to prove interop. A single `CatalogObject` with `ra`, `dec`, + `redshift` is enough to validate the entire pipeline. +4. **Static fixture files.** Generate `.fbs` → FlatBuffer binaries once with + `flatc --binary`, check them into the repo, and write a cross-language test + that both Rust and Odin read the same fixture identically. This is the + backbone of your integration test story (see `08-testing-strategies.md`). \ No newline at end of file -- 2.52.0 From 8c74efcedb930a5527ffa64af7d1e50e2db0ec98 Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Sun, 6 Sep 2026 14:08:13 -0600 Subject: [PATCH 3/9] updated the runner to utilize caching --- .gitea/workflows/ci.yml | 9 +++++++++ .gitea/workflows/release.yml | 18 ++++++++++++++++++ scripts/install_odin.sh | 21 +++++++++++++++------ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8182802..b80f4ed 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -66,6 +66,15 @@ jobs: with: submodules: recursive + # Cache the extracted Odin install so only the first run downloads the + # ~60 MB tarball; keyed on the pinned version (cache paths live outside + # the checkout, so no sharing between jobs/refs). + - name: Cache Odin + uses: actions/cache@v4 + with: + path: /tmp/odin + key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }} + - name: Install Odin ${{ env.ODIN_VERSION }} run: scripts/install_odin.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f3e347e..fb2877f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -34,6 +34,12 @@ jobs: with: submodules: recursive + - name: Cache Odin + uses: actions/cache@v4 + with: + path: /tmp/odin + key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }} + - name: Install Odin ${{ env.ODIN_VERSION }} run: scripts/install_odin.sh @@ -76,6 +82,12 @@ jobs: with: submodules: recursive + - name: Cache Odin + uses: actions/cache@v4 + with: + path: /tmp/odin + key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }} + - name: Install Odin ${{ env.ODIN_VERSION }} run: scripts/install_odin.sh @@ -139,6 +151,12 @@ jobs: with: submodules: recursive + - name: Cache Odin + uses: actions/cache@v4 + with: + path: /tmp/odin + key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }} + - name: Install Odin ${{ env.ODIN_VERSION }} run: scripts/install_odin.sh diff --git a/scripts/install_odin.sh b/scripts/install_odin.sh index 02670e3..be5e252 100755 --- a/scripts/install_odin.sh +++ b/scripts/install_odin.sh @@ -18,10 +18,19 @@ case "$ARCH" in ;; esac -curl -fL -o /tmp/odin.tar.gz \ - "https://github.com/odin-lang/Odin/releases/download/${VERSION}/odin-linux-${OBJ_ARCH}-${VERSION}.tar.gz" -mkdir -p /tmp/odin -tar -xzf /tmp/odin.tar.gz -C /tmp/odin --strip-components=1 +# If a prior job restored the install from cache, reuse it instead of +# re-downloading. The extracted Odin binary already on PATH is authoritative; +# otherwise fetch the version (plus the raylib workaround) fresh. +if [ -x /tmp/odin/odin ]; then + echo "Odin ${VERSION} found in cache (host arch: ${ARCH}, release arch: ${OBJ_ARCH})" + BIN_DIR=/tmp/odin +else + curl -fL -o /tmp/odin.tar.gz \ + "https://github.com/odin-lang/Odin/releases/download/${VERSION}/odin-linux-${OBJ_ARCH}-${VERSION}.tar.gz" + mkdir -p /tmp/odin + tar -xzf /tmp/odin.tar.gz -C /tmp/odin --strip-components=1 + BIN_DIR=/tmp/odin +fi # Work around an Odin binding bug: for ODIN_ARCH == .arm64 the vendored # raylib references `vendor/raylib/linux-arm/libraylib.a`, but the release @@ -33,7 +42,7 @@ if [ "${OBJ_ARCH}" = "arm64" ] \ fi if [ -n "${GITHUB_PATH:-}" ]; then - echo "/tmp/odin" >> "$GITHUB_PATH" + echo "${BIN_DIR}" >> "$GITHUB_PATH" fi -echo "Installed Odin ${VERSION} (host arch: ${ARCH}, release arch: ${OBJ_ARCH})" +echo "Odin ${VERSION} ready (host arch: ${ARCH}, release arch: ${OBJ_ARCH})" -- 2.52.0 From 3803787fe8444133b85d65741f2614473d575e5a Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Sun, 6 Sep 2026 14:28:48 -0600 Subject: [PATCH 4/9] updated documentation to have mermaid diagrams; updated AGENTS.md to note that all future diagrams should be mermaid diagrams first with text-based diagrams as fallback where not applicable --- AGENTS.md | 1 + .../data-streaming/01-architecture.md | 33 ++++++++++--------- .../data-streaming/02-flatbuffers-overview.md | 29 ++++++++++------ .../data-streaming/03-rust-integration.md | 12 +++++++ .../04-odin-client-integration.md | 29 ++++++++++++---- .../data-streaming/05-streaming-protocols.md | 33 ++++++++++++++----- .../data-streaming/07-alternatives.md | 9 +++++ .../data-streaming/08-testing-strategies.md | 18 +++++++++- .../ai/research/data-streaming/README.md | 10 ++++++ 9 files changed, 133 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e11b77..144596b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ The root `Makefile` is a lean delegator: base commands (`run`, `build`, `test`, - Odin code lives in `gui/src/`; external deps go in `gui/lib/` and are wired via `-collection:lib=lib/local` (or a git submodule imported by relative path). - Everything is plain `make` — no Taskfile — so CI (Gitea Actions) can call `make` directly. - Keep the renderer (gui/), API (api/), and infra (infra/) logically separated; each owns its own Makefile, and the root Makefile is the only place that ties them together. +- **Diagrams in this repo's documentation are Mermaid flowcharts.** Gitea renders ` ```mermaid ` fenced blocks natively. Prefer a Mermaid flowchart over ASCII art / box-drawing diagrams; if a diagram genuinely can't be expressed as a flowchart, fall back to a plain text-based markdown diagram (e.g. a code block or table) rather than hand-rawn ASCII boxes. ## Gotchas - Odin version is pinned in `.gitea/workflows/*.yml` (`ODIN_VERSION`) and defaults in `scripts/install_odin.sh`; bump both together when tracking a new release. diff --git a/resources/ai/research/data-streaming/01-architecture.md b/resources/ai/research/data-streaming/01-architecture.md index b649458..3504594 100644 --- a/resources/ai/research/data-streaming/01-architecture.md +++ b/resources/ai/research/data-streaming/01-architecture.md @@ -59,11 +59,13 @@ get_catalog_objects :: proc(url: string, catalog_name: string) // nil ## Data flow gap -``` -[DESI catalog store] --(future)--> [Rust/axum API] --(nothing today)--> [Odin + raylib GUI] - ^ ^ - | serde JSON models | hand-mirrored structs - | | (stubs, never used) +```mermaid +flowchart LR + A["DESI catalog store"] + B["Rust / axum API
serde JSON models"] + C["Odin + raylib GUI
hand-mirrored structs
stubs, never used
"] + A -. "future ingestion" .-> B + B --x|"nothing today"| C ``` There is **no live data flow**. The API currently returns JSON placeholders; the @@ -116,17 +118,16 @@ response protocol with a cheap binary payload would fit this well. ## High-level target architecture -``` - catalog.fbs (single source of truth, checked into repo) - | - +--------+---------+ - | | -flatc --rust flatcc --c (or hand-rolled Odin reader) - | | -api/ (Rust) gui/ (Odin + raylib) - | ^ - | HTTP / WebSocket (framed FlatBuffer binary stream) - +------------------+ +```mermaid +flowchart TB + S["catalog.fbs
single source of truth
checked into repo
"] + R["flatc --rust"] + C["flatcc --c
or hand-rolled Odin reader"] + API["api/ · Rust"] + GUI["gui/ · Odin + raylib"] + S --> R --> API + S --> C --> GUI + API <-->|"HTTP / WebSocket
framed FlatBuffer binary stream"| GUI ``` - One schema file. Two generators. Byte-for-byte identical wire format. diff --git a/resources/ai/research/data-streaming/02-flatbuffers-overview.md b/resources/ai/research/data-streaming/02-flatbuffers-overview.md index e3549c3..916b423 100644 --- a/resources/ai/research/data-streaming/02-flatbuffers-overview.md +++ b/resources/ai/research/data-streaming/02-flatbuffers-overview.md @@ -105,19 +105,28 @@ These are the "thou shalt" rules for keeping buffers compatible: ## Reading a buffer (conceptual) +Buffer layout: + +```mermaid +flowchart LR + subgraph BUF["bytes: &[u8]"] + O["uoffset
root table offset"] + FI["file_identifier
(optional)"] + D["tables · vtables · data"] + end + O --> FI --> D ``` -bytes: &[u8] - ┌─────────────────────────────┐ - │ uoffset (root table offset) │ - │ file_identifier (optional) │ - │ ... tables, vtables, data ...│ - └─────────────────────────────┘ +Access sequence — each field is a few offset dereferences and a read: -root = follow(bytes) // jump to root table via uoffset -vtable = root - root.vtable_off // locate vtable for this table -field_ra = vtable.slot_ra != 0 // present? -if present: ra = read_f64(bytes, root + slot_ra) +```mermaid +flowchart TD + R["root = follow(bytes)
jump to root table via uoffset"] + V["vtable = root − root.vtable_off
locate vtable for this table"] + Q{"field slot present?"} + R --> V --> Q + Q -- "no" --> DEF["use schema default"] + Q -- "yes" --> RD["ra = read_f64(bytes, root + slot_ra)"] ``` There is **no parsing loop**. Each accessor is a few offset dereferences and a diff --git a/resources/ai/research/data-streaming/03-rust-integration.md b/resources/ai/research/data-streaming/03-rust-integration.md index 4629d58..acc0b9d 100644 --- a/resources/ai/research/data-streaming/03-rust-integration.md +++ b/resources/ai/research/data-streaming/03-rust-integration.md @@ -196,6 +196,18 @@ Notes: ## Building buffers efficiently (Rust specifics) +Build order is **back-to-front** (children before parents): + +```mermaid +flowchart TB + A["create_string / create_vector
children first"] + B["create nested child tables"] + C["create parent table
ObjectBatch::create(&args)"] + D["builder.finish(root, Some("DESI"))"] + E["Bytes::copy_from_slice(fbb.finished_data())
→ HTTP / WebSocket response"] + A --> B --> C --> D --> E +``` + - `FlatBufferBuilder::with_capacity(n)` pre-allocates; `reset()` reuses the buffer across messages. In a loop streaming batches, create one builder, reuse it — avoid repeated reallocation. diff --git a/resources/ai/research/data-streaming/04-odin-client-integration.md b/resources/ai/research/data-streaming/04-odin-client-integration.md index 52f9c98..c406bd6 100644 --- a/resources/ai/research/data-streaming/04-odin-client-integration.md +++ b/resources/ai/research/data-streaming/04-odin-client-integration.md @@ -17,6 +17,21 @@ has two C-related access points: schema plus a small `libflatccrt.a` runtime. Works via the C ABI, so Odin's `foreign import` can consume it. +Choosing a path: + +```mermaid +flowchart TD + NAT{"primary target is native desktop?"} + NAT -- "yes" --> CGO{"want to avoid C in the build?"} + CGO -- "yes" --> PATHA["Path B · pure-Odin reader
hand-rolled, no C dependency"] + CGO -- "no" --> PATHA + CGO -- "prefer proven lib / less maintenance" --> PATHC["Path A · FFI to FlatCC
bind generated C headers"] + PATHC --> REUSE["Path C · OdinArrow reuse
or borrow its decode patterns"] + NAT -- "no · browser/WASM" --> PATHD["Path D · TS/JS interop
official JS lib → typed arrays into WASM"] +``` + +Index of paths: + | Path | Effort | Zero-copy on reads | Notes | |---|---|---|---| | A: FFI to FlatCC (C runtime) | Medium | ✅ | Bind generated C headers to Odin `foreign` | @@ -175,12 +190,14 @@ typed Odin slices. The rendering win only materializes if data stays zero-copy **into the frame loop**: -1. Fetch frame bytes → owned `[dynamic]u8` (or a slice pinned for the lifetime - of the frame). -2. `verify` the buffer once. -3. Get `ra_slice := ObjectBatch.ra(&buf)` → `[]f64` view. -4. Per object in `update()`/`draw()`: read `ra[i]`, `dec[i]`, `z[i]` straight - from that slice; build `rl.Vector3`; `DrawPoint3D`. +```mermaid +flowchart LR + A["fetch frame bytes → [dynamic]u8
or a slice pinned for the frame"] + B["verify the buffer once"] + C["ObjectBatch.ra(&buf) → []f64 view"] + D["per object in update()/draw()
ra[i] · dec[i] · z[i] → rl.Vector3 → DrawPoint3D"] + A --> B --> C --> D +``` No per-object allocation. The current `Galaxy { position, color }` dynamic array in `main.odin` is the data structure you'd replace with *slices into the diff --git a/resources/ai/research/data-streaming/05-streaming-protocols.md b/resources/ai/research/data-streaming/05-streaming-protocols.md index 89a5798..76b8f48 100644 --- a/resources/ai/research/data-streaming/05-streaming-protocols.md +++ b/resources/ai/research/data-streaming/05-streaming-protocols.md @@ -24,14 +24,17 @@ offers two built-in mechanisms plus the community pattern: ### Option 1: Size-prefixed FlatBuffers (built-in) +```mermaid +flowchart LR + A["u32 LE
total buffer len
size prefix"] + B["u32 LE
root table offset"] + C["file identifier
(4 bytes)"] + D["tables · vtables · data"] + A --> B --> C --> D +``` + ```rust builder.finish_size_prefixed(root, Some("DESI")); -// +---------------------------+ -// | u32 LE: total buffer len | <-- size prefix -// | u32 LE: root table offset | -// | file identifier (4 bytes) | -// | ... data ... | -// +---------------------------+ ``` Reader side: @@ -53,8 +56,12 @@ message kinds). ### Option 2: Custom length-prefix framing (like `flatstream`) -``` -[ u32 LE: message_len ] [ optional checksum (e.g. u32 crc/xxhash) ] [ flatbuffer payload ] +```mermaid +flowchart LR + A["u32 LE
message_len"] + B["optional checksum
(u32 crc / xxhash)"] + C["FlatBuffer payload"] + A --> B --> C ``` - `flatstream-rs` (see `03-rust-integration.md`) is a reference implementation @@ -260,6 +267,16 @@ FlatBuffers long-term (mmap-friendly, page-in-what-you-touch). ## Decision summary for this repo +```mermaid +flowchart TD + A["HTTP GET → one FlatBuffer body per batch
validate Rust builder + Odin reader"] + B["WebSocket → one Binary message per batch
interactive path · no custom framing"] + C["Self-identifying messages
file_identifier "DESI""] + D["size-prefixed / flatstream-style framing
or HTTP-range + mmap for static catalogs"] + A --> B --> C + C -. "later, if needed" .-> D +``` + 1. Start with **HTTP GET → one FlatBuffer body per batch** to validate the Rust builder + Odin reader (no protocol work at all). 2. Then add **WebSocket** with one `Binary` message per batch (no custom framing) diff --git a/resources/ai/research/data-streaming/07-alternatives.md b/resources/ai/research/data-streaming/07-alternatives.md index cf16a6e..d6761ff 100644 --- a/resources/ai/research/data-streaming/07-alternatives.md +++ b/resources/ai/research/data-streaming/07-alternatives.md @@ -108,6 +108,15 @@ message shape — see `10-performance-benchmarks.md`.) ## Bottom line +```mermaid +flowchart TD + Q1{"zero-copy reads
in the per-frame render loop?"} + Q1 -- "no" --> PB["Protobuf / gRPC
decode once into draw buffers"] + Q1 -- "yes" --> Q2{"truly columnar?
millions of rows"} + Q2 -- "yes" --> ARR["Apache Arrow IPC
via OdinArrow"] + Q2 -- "no · batched vectors" --> FB["FlatBuffers · this proposal"] +``` + - **FlatBuffers is the best default** for this project: the zero-copy read model matches the render loop, the wire format is compact for numeric vectors, schema evolution fits DESI's release cadence, and the Rust + WASM/JS official story diff --git a/resources/ai/research/data-streaming/08-testing-strategies.md b/resources/ai/research/data-streaming/08-testing-strategies.md index 2aaf26b..67bb3c0 100644 --- a/resources/ai/research/data-streaming/08-testing-strategies.md +++ b/resources/ai/research/data-streaming/08-testing-strategies.md @@ -139,7 +139,23 @@ side / flatc* — see the "cross-language fixture" section below. ### 4. Cross-language conformance suite (THE key integration test) -This is the test that actually catches incompatibility. Design: +This is the test that actually catches incompatibility. Pipeline: + +```mermaid +flowchart LR + S["schema/catalog.fbs"] + J["testdata/catalog_sample.json"] + S --> F["flatc --binary"] + J --> F + F --> BIN["committed .bin fixtures
repo-checked-in"] + BIN --> OT["Odin tests
assert identical values"] + BIN --> RT["Rust tests
assert expected values"] + RT -. "deterministic builder" .-> PAR["byte-for-byte parity"] + OT -. "reads it" .-> PAR + PAR -. "catch drift" .-> F +``` + +Design: 1. **Static fixtures, committed to the repo** (`testdata/*.bin`): - Built once by `flatc --binary .fbs .json` (deterministic, diff --git a/resources/ai/research/data-streaming/README.md b/resources/ai/research/data-streaming/README.md index 79848d7..f308b34 100644 --- a/resources/ai/research/data-streaming/README.md +++ b/resources/ai/research/data-streaming/README.md @@ -70,6 +70,16 @@ directly from the buffer each frame without allocations. ## Immediate Next Steps (when you're ready to implement) +```mermaid +flowchart TD + A["Prototype schema
catalog.fbs: Catalog · CatalogObject · ServerMessage union"] + B["Generate Rust code
flatc --rust → api/build.rs · serve WS via axum"] + C["Prototype the Odin reader
flatcc FFI · pure-Odin · OdinArrow"] + D["Static fixture files
flatc --binary → committed .bin"] + E["Cross-language tests
Rust + Odin read the same fixtures identically"] + A --> B --> C --> D --> E +``` + 1. **Prototype schema first.** Write `catalog.fbs` covering `Catalog`, `CatalogObject`, and a `ServerMessage` union (handshake / catalog list / chunk of objects / end). 2. **Generate Rust code** via `flatc --rust` in an `api/build.rs` (see -- 2.52.0 From ac2d30510ca9cda71b9a50133168ec8b798acb53 Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Mon, 7 Sep 2026 13:27:51 -0600 Subject: [PATCH 5/9] added support for dotenv files and config --- .gitignore | 2 + gui/Makefile | 2 +- gui/lib/local/dotenv/dotenv.odin | 95 ++++++++++++++++++++++++++++++++ gui/src/config.odin | 29 ++++++++++ gui/src/error.odin | 20 +++++++ gui/src/main.odin | 15 ++--- gui/test/dotenv_test.odin | 87 +++++++++++++++++++++++++++++ 7 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 gui/lib/local/dotenv/dotenv.odin create mode 100644 gui/src/config.odin create mode 100644 gui/src/error.odin create mode 100644 gui/test/dotenv_test.odin diff --git a/.gitignore b/.gitignore index 0753ab7..9dbd8bb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ resources/ai/sessions infra/Pulumi.*.yaml.backup infra/desi-explorer-infra api/target/ +.env +.env.* diff --git a/gui/Makefile b/gui/Makefile index 84951f2..00df428 100644 --- a/gui/Makefile +++ b/gui/Makefile @@ -46,7 +46,7 @@ build-web: ## WebAssembly build -> build/web (needs emscripten) test: ## Run Odin unit tests @mkdir -p lib/local - $(ODIN) test src $(ODIN_FLAGS) + $(ODIN) test test $(ODIN_FLAGS) clean: ## Remove build artifacts rm -rf $(BIN) build diff --git a/gui/lib/local/dotenv/dotenv.odin b/gui/lib/local/dotenv/dotenv.odin new file mode 100644 index 0000000..8084b6d --- /dev/null +++ b/gui/lib/local/dotenv/dotenv.odin @@ -0,0 +1,95 @@ +package dotenv + +import "core:os" +import "core:strconv" +import "core:strings" + +// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated +// with allocator. Blank lines, lines starting with '#', and lines without a +// '=' are skipped. Keys and values are trimmed; values may be wrapped in +// double quotes. Precedence with the real process environment is handled by +// the get_* accessors (real env vars win over the file). +parse :: proc(src: string, allocator := context.allocator) -> map[string]string { + result := make(map[string]string, allocator) + + it := src + for line in strings.split_lines_iterator(&it) { + tr := strings.trim_space(line) + if len(tr) == 0 || strings.has_prefix(tr, "#") { + continue + } + + eq := strings.index_byte(tr, '=') + if eq < 0 { + continue + } + + key := strings.trim_space(tr[:eq]) + if key == "" { + continue + } + + value := strings.trim_space(tr[eq + 1:]) + if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' { + value = value[1:len(value) - 1] + } + + // clone so the map outlives the source buffer (e.g. a freed file read) + result[strings.clone(key, allocator)] = strings.clone(value, allocator) + } + + return result +} + +// parse_file reads a dotenv file from disk and parses it. It returns +// (nil, false) when the file cannot be read (e.g. it does not exist). +parse_file :: proc(filename: string, allocator := context.allocator) -> (map[string]string, bool) { + data, err := os.read_entire_file(filename, allocator) + if err != nil { + return nil, false + } + defer delete(data) + + return parse(string(data), allocator), true +} + +// destroy frees the cloned keys/values and the map itself. Use it to release +// a map returned by parse/parse_file (plain delete does not free the strings). +destroy :: proc(env: map[string]string) { + for key, value in env { + delete(key) + delete(value) + } + delete(env) +} + +// get_string returns the value for key from the real process environment if +// set, otherwise from the parsed dotenv map (or "" if neither has it). +get_string :: proc(env: map[string]string, key: string) -> string { + if value, found := os.lookup_env(key, context.temp_allocator); found { + return value + } + return env[key] +} + +// get_bool resolves key and parses it as a boolean (true/false, 1/0, +// yes/no, on/off). Missing or unparsable values yield default. +get_bool :: proc(env: map[string]string, key: string, default := false) -> bool { + if v := get_string(env, key); v != "" { + if parsed, ok := strconv.parse_bool(v); ok { + return parsed + } + } + return default +} + +// get_int resolves key and parses it as an integer. Missing or unparsable +// values yield default. +get_int :: proc(env: map[string]string, key: string, default := 0) -> int { + if v := get_string(env, key); v != "" { + if parsed, ok := strconv.parse_int(v); ok { + return parsed + } + } + return default +} \ No newline at end of file diff --git a/gui/src/config.odin b/gui/src/config.odin new file mode 100644 index 0000000..f019d99 --- /dev/null +++ b/gui/src/config.odin @@ -0,0 +1,29 @@ +package main + +import "lib:dotenv" + +Config :: struct { + api_url: string, +} + +get_config :: proc() -> (^Config, ^Error) { + env, _ := dotenv.parse_file(".env", context.temp_allocator) + defer dotenv.destroy(env) + + c := new(Config) + + c.api_url = dotenv.get_string(env, "API_URL") + + if err := validate_config(c); err != nil { + return nil, err + } + + return c, nil +} + +validate_config :: proc(c: ^Config) -> ^Error { + err := new(Error) + if c.api_url == "" do err.type = .Config; err.message = "'API_URL' is required" + + return err +} \ No newline at end of file diff --git a/gui/src/error.odin b/gui/src/error.odin new file mode 100644 index 0000000..daeb84f --- /dev/null +++ b/gui/src/error.odin @@ -0,0 +1,20 @@ +package main + +import "core:fmt" +import "core:strings" +ErrorType :: enum { + Config, + API, +} + +Error :: struct { + type: ErrorType, + message: string, +} + +format_error :: proc(err: ^Error) -> string { + sb := strings.builder_make(context.temp_allocator) + + return fmt.sbprintf(&sb, "[%s] => %s", err.type, err.message) +} + diff --git a/gui/src/main.odin b/gui/src/main.odin index 67285fb..315b1cb 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -24,6 +24,13 @@ universe: [dynamic]Galaxy rng: rand.Default_Random_State main :: proc() { + + c: ^Config + + if c, err := get_config(); err != nil { + panic(format_error(err)) + } + rng = rand.create(0xDE51_0000) context.random_generator = rand.default_random_generator(&rng) @@ -103,11 +110,5 @@ draw :: proc() { } rl.DrawFPS(10, 10) - rl.DrawText( - "DESI Explorer — drag to rotate, scroll to zoom", - 10, - 34, - 18, - rl.RAYWHITE, - ) + rl.DrawText("DESI Explorer — drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE) } diff --git a/gui/test/dotenv_test.odin b/gui/test/dotenv_test.odin new file mode 100644 index 0000000..d7ba8f6 --- /dev/null +++ b/gui/test/dotenv_test.odin @@ -0,0 +1,87 @@ +package dotenv_tests + +import "core:os" +import "core:testing" +import "lib:dotenv" + +@(test) +test_parse_basic :: proc(t: ^testing.T) { + env := dotenv.parse("API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n") + defer dotenv.destroy(env) + + testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080") + testing.expect(t, env["DEBUG"] == "true") + testing.expect(t, env["PORT"] == "8080") +} + +@(test) +test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) { + env := dotenv.parse("# leading comment\n\n \nFOO=bar \nBAZ = qux \n") + defer dotenv.destroy(env) + + testing.expect(t, env["FOO"] == "bar", "value should be trimmed") + testing.expect(t, env["BAZ"] == "qux", "key and value should be trimmed around '='") + testing.expect(t, "API_URL" not_in env, "comment-only lines should not be parsed") +} + +@(test) +test_parse_quoted_values :: proc(t: ^testing.T) { + env := dotenv.parse("GREETING=\"hello world\"\nEMPTY=\"\"\n") + defer dotenv.destroy(env) + + testing.expect(t, env["GREETING"] == "hello world", "quoted value with inner space") + testing.expect(t, env["EMPTY"] == "", "double-quoted empty value") +} + +@(test) +test_parse_skips_lines_without_equals :: proc(t: ^testing.T) { + env := dotenv.parse("not-an-assignment\nOK=yep\n") + defer dotenv.destroy(env) + + testing.expect(t, env["OK"] == "yep") + testing.expect(t, "not-an-assignment" not_in env, "line without '=' should be skipped") +} + +@(test) +test_get_string_falls_back_to_file :: proc(t: ^testing.T) { + env := dotenv.parse("FOO=from_file\n") + defer dotenv.destroy(env) + + testing.expect(t, dotenv.get_string(env, "FOO") == "from_file", "missing env var should use file value") + testing.expect(t, dotenv.get_string(env, "MISSING") == "", "absent everywhere should be empty") +} + +@(test) +test_get_string_prefers_real_env :: proc(t: ^testing.T) { + env := dotenv.parse("FOO=from_file\n") + defer dotenv.destroy(env) + + testing.expect(t, os.set_env("FOO", "from_env") == nil) + defer os.unset_env("FOO") + + testing.expect(t, dotenv.get_string(env, "FOO") == "from_env", "real env var should win over file") +} + +@(test) +test_get_bool :: proc(t: ^testing.T) { + env := dotenv.parse("A=true\nB=0\nC=FALSE\nD=garbage\n") + defer dotenv.destroy(env) + + testing.expect(t, dotenv.get_bool(env, "A", false), "\"true\" should parse to true") + testing.expect(t, !dotenv.get_bool(env, "B", true), "\"0\" should parse to false") + testing.expect(t, !dotenv.get_bool(env, "C", true), "\"FALSE\" should parse to false") + testing.expect(t, dotenv.get_bool(env, "D", true), "unparsable value should fall back to default") + testing.expect(t, dotenv.get_bool(env, "MISSING", true), "missing key should fall back to default") +} + +@(test) +test_get_int :: proc(t: ^testing.T) { + env := dotenv.parse("PORT=8080\nNEG=-42\nHEX=0x1F\nGARBAGE=abc\n") + defer dotenv.destroy(env) + + testing.expect(t, dotenv.get_int(env, "PORT", -1) == 8080, "decimal int") + testing.expect(t, dotenv.get_int(env, "NEG", 0) == -42, "negative int") + testing.expect(t, dotenv.get_int(env, "HEX", 0) == 31, "hex int") + testing.expect(t, dotenv.get_int(env, "GARBAGE", 7) == 7, "unparsable value should fall back to default") + testing.expect(t, dotenv.get_int(env, "MISSING", 7) == 7, "missing key should fall back to default") +} \ No newline at end of file -- 2.52.0 From ba24005bfb31a8be18858098b310c7698b07885b Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Mon, 7 Sep 2026 14:35:30 -0600 Subject: [PATCH 6/9] changed how tests are ran; updated dotenv to handle parsing fields into structs --- gui/Makefile | 9 +- gui/lib/local/dotenv/dotenv.odin | 95 ---------- gui/lib/local/dotenv/src/dotenv.odin | 165 +++++++++++++++++ gui/lib/local/dotenv/test/dotenv_test.odin | 201 +++++++++++++++++++++ gui/src/config.odin | 16 +- gui/test/dotenv_test.odin | 87 --------- 6 files changed, 386 insertions(+), 187 deletions(-) delete mode 100644 gui/lib/local/dotenv/dotenv.odin create mode 100644 gui/lib/local/dotenv/src/dotenv.odin create mode 100644 gui/lib/local/dotenv/test/dotenv_test.odin delete mode 100644 gui/test/dotenv_test.odin diff --git a/gui/Makefile b/gui/Makefile index 00df428..94dca55 100644 --- a/gui/Makefile +++ b/gui/Makefile @@ -46,7 +46,14 @@ build-web: ## WebAssembly build -> build/web (needs emscripten) test: ## Run Odin unit tests @mkdir -p lib/local - $(ODIN) test test $(ODIN_FLAGS) + @if ls test/*.odin >/dev/null 2>&1; then \ + echo "== gui/test =="; \ + $(ODIN) test test $(ODIN_FLAGS); \ + fi + @for dir in $$(find lib/local -name '*_test.odin' -exec dirname {} \; | sort -u); do \ + echo "== $$dir =="; \ + $(ODIN) test "$$dir" $(ODIN_FLAGS); \ + done clean: ## Remove build artifacts rm -rf $(BIN) build diff --git a/gui/lib/local/dotenv/dotenv.odin b/gui/lib/local/dotenv/dotenv.odin deleted file mode 100644 index 8084b6d..0000000 --- a/gui/lib/local/dotenv/dotenv.odin +++ /dev/null @@ -1,95 +0,0 @@ -package dotenv - -import "core:os" -import "core:strconv" -import "core:strings" - -// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated -// with allocator. Blank lines, lines starting with '#', and lines without a -// '=' are skipped. Keys and values are trimmed; values may be wrapped in -// double quotes. Precedence with the real process environment is handled by -// the get_* accessors (real env vars win over the file). -parse :: proc(src: string, allocator := context.allocator) -> map[string]string { - result := make(map[string]string, allocator) - - it := src - for line in strings.split_lines_iterator(&it) { - tr := strings.trim_space(line) - if len(tr) == 0 || strings.has_prefix(tr, "#") { - continue - } - - eq := strings.index_byte(tr, '=') - if eq < 0 { - continue - } - - key := strings.trim_space(tr[:eq]) - if key == "" { - continue - } - - value := strings.trim_space(tr[eq + 1:]) - if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' { - value = value[1:len(value) - 1] - } - - // clone so the map outlives the source buffer (e.g. a freed file read) - result[strings.clone(key, allocator)] = strings.clone(value, allocator) - } - - return result -} - -// parse_file reads a dotenv file from disk and parses it. It returns -// (nil, false) when the file cannot be read (e.g. it does not exist). -parse_file :: proc(filename: string, allocator := context.allocator) -> (map[string]string, bool) { - data, err := os.read_entire_file(filename, allocator) - if err != nil { - return nil, false - } - defer delete(data) - - return parse(string(data), allocator), true -} - -// destroy frees the cloned keys/values and the map itself. Use it to release -// a map returned by parse/parse_file (plain delete does not free the strings). -destroy :: proc(env: map[string]string) { - for key, value in env { - delete(key) - delete(value) - } - delete(env) -} - -// get_string returns the value for key from the real process environment if -// set, otherwise from the parsed dotenv map (or "" if neither has it). -get_string :: proc(env: map[string]string, key: string) -> string { - if value, found := os.lookup_env(key, context.temp_allocator); found { - return value - } - return env[key] -} - -// get_bool resolves key and parses it as a boolean (true/false, 1/0, -// yes/no, on/off). Missing or unparsable values yield default. -get_bool :: proc(env: map[string]string, key: string, default := false) -> bool { - if v := get_string(env, key); v != "" { - if parsed, ok := strconv.parse_bool(v); ok { - return parsed - } - } - return default -} - -// get_int resolves key and parses it as an integer. Missing or unparsable -// values yield default. -get_int :: proc(env: map[string]string, key: string, default := 0) -> int { - if v := get_string(env, key); v != "" { - if parsed, ok := strconv.parse_int(v); ok { - return parsed - } - } - return default -} \ No newline at end of file diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin new file mode 100644 index 0000000..ebfccb8 --- /dev/null +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -0,0 +1,165 @@ +package dotenv + +import "base:runtime" +import "core:os" +import "core:reflect" +import "core:strconv" +import "core:strings" + +// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated +// with allocator. Blank lines, lines starting with '#', and lines without a +// '=' are skipped. Keys and values are trimmed; values may be wrapped in +// double quotes. Real process environment variables take precedence over the +// file. The returned map owns its keys/values; release it with destroy. +@(private) +parse :: proc( + src: string, + allocator := context.allocator, +) -> map[string]string { + result := make(map[string]string, allocator) + + it := src + for line in strings.split_lines_iterator(&it) { + tr := strings.trim_space(line) + if len(tr) == 0 || strings.has_prefix(tr, "#") { + continue + } + + eq := strings.index_byte(tr, '=') + if eq < 0 { + continue + } + + key := strings.trim_space(tr[:eq]) + if key == "" { + continue + } + + value := strings.trim_space(tr[eq + 1:]) + if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' { + value = value[1:len(value) - 1] + } + + // real process env vars win over the file + if override, found := os.lookup_env(key, context.temp_allocator); found { + value = override + } + + // clone so the map outlives the source buffer (e.g. a freed file read) + result[strings.clone(key, allocator)] = strings.clone(value, allocator) + } + + return result +} + +// parse_file reads a dotenv file from disk and parses it into a map. It +// returns (nil, false) when the file cannot be read (e.g. it does not exist). +parse_file :: proc( + filename: string, + allocator := context.allocator, +) -> ( + map[string]string, + bool, +) { + data, err := os.read_entire_file(filename, allocator) + if err != nil { + return nil, false + } + defer delete(data) + + return parse(string(data), allocator), true +} + +// destroy frees the cloned keys/values and the map itself. Use it to release +// a map returned by parse/parse_file (plain delete does not free the strings). +destroy :: proc(env: map[string]string) { + for key, value in env { + delete(key) + delete(value) + } + delete(env) +} + +// decode populates dest's fields from env, matching each field by name +// (case-insensitively, so API_URL maps onto api_url). Values are converted +// to the field's type: string is cloned as-is into allocator, integers are +// parsed with strconv.parse_int (decimal/hex/negative), booleans with +// strconv.parse_bool, and floats with strconv.parse_f64. Keys missing from +// env leave the field at its zero value. It returns false if a present value +// cannot be converted to the field's type. +decode :: proc( + env: map[string]string, + dest: ^$T, + allocator := context.allocator, +) -> bool { + ti := reflect.type_info_base(type_info_of(T)) + fields, ok := ti.variant.(runtime.Type_Info_Struct) + if !ok { + return false + } + + name: string + value: string + field_ptr := rawptr(dest) + for _, i in fields.names[:fields.field_count] { + name = fields.names[i] + value = "" + found := false + for key, v in env { + if key == name || strings.equal_fold(key, name) { + value, found = v, true + break + } + } + if !found { + continue + } + + field_ptr = rawptr(uintptr(dest) + fields.offsets[i]) + field_ti := reflect.type_info_base(fields.types[i]) + #partial switch variant in field_ti.variant { + case runtime.Type_Info_String: + (^string)(field_ptr)^ = strings.clone(value, allocator) + case runtime.Type_Info_Integer: + parsed, err := strconv.parse_int(value) + if !err { + return false + } + switch field_ti.size { + case 1: + (^i8)(field_ptr)^ = cast(i8)parsed + case 2: + (^i16)(field_ptr)^ = cast(i16)parsed + case 4: + (^i32)(field_ptr)^ = cast(i32)parsed + case 8: + (^i64)(field_ptr)^ = cast(i64)parsed + case: + return false + } + case runtime.Type_Info_Boolean: + parsed, err := strconv.parse_bool(value) + if !err { + return false + } + (^bool)(field_ptr)^ = parsed + case runtime.Type_Info_Float: + parsed, err := strconv.parse_f64(value) + if !err { + return false + } + switch field_ti.size { + case 4: + (^f32)(field_ptr)^ = cast(f32)parsed + case 8: + (^f64)(field_ptr)^ = parsed + case: + return false + } + case: + // unsupported field type (slices, pointers, ...) is left untouched + } + } + + return true +} diff --git a/gui/lib/local/dotenv/test/dotenv_test.odin b/gui/lib/local/dotenv/test/dotenv_test.odin new file mode 100644 index 0000000..e0f17fa --- /dev/null +++ b/gui/lib/local/dotenv/test/dotenv_test.odin @@ -0,0 +1,201 @@ +package dotenv_tests + +import "core:os" +import "core:strings" +import "core:testing" +import dotenv "lib:dotenv/src" + +Test_Config :: struct { + api_url: string, + debug: bool, + port: int, + ratio: f64, +} + +// load_env writes src to a unique temp file and parses it via parse_file. +// The returned map owns its strings; callers must destroy it. +load_env :: proc(t: ^testing.T, src: string) -> map[string]string { + dir, err := os.make_directory_temp("", "dotenv_test_*", context.allocator) + testing.expect(t, err == nil, "expected temp dir to be created") + defer os.remove_all(dir) + defer delete(dir) + + path := strings.concatenate({dir, "/.env"}) + defer delete(path) + + testing.expect( + t, + os.write_entire_file(path, src) == nil, + "expected file write to succeed", + ) + + env, ok := dotenv.parse_file(path) + testing.expect(t, ok, "expected parse_file to succeed") + return env +} + +@(test) +test_parse_basic :: proc(t: ^testing.T) { + env := load_env(t, "API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n") + defer dotenv.destroy(env) + + testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080") + testing.expect(t, env["DEBUG"] == "true") + testing.expect(t, env["PORT"] == "8080") +} + +@(test) +test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) { + env := load_env(t, "# leading comment\n\n \nFOO=bar \nBAZ = qux \n") + defer dotenv.destroy(env) + + testing.expect(t, env["FOO"] == "bar", "value should be trimmed") + testing.expect( + t, + env["BAZ"] == "qux", + "key and value should be trimmed around '='", + ) + testing.expect( + t, + "API_URL" not_in env, + "comment-only lines should not be parsed", + ) +} + +@(test) +test_parse_quoted_values :: proc(t: ^testing.T) { + env := load_env(t, "GREETING=\"hello world\"\nEMPTY=\"\"\n") + defer dotenv.destroy(env) + + testing.expect( + t, + env["GREETING"] == "hello world", + "quoted value with inner space", + ) + testing.expect(t, env["EMPTY"] == "", "double-quoted empty value") +} + +@(test) +test_parse_skips_lines_without_equals :: proc(t: ^testing.T) { + env := load_env(t, "not-an-assignment\nOK=yep\n") + defer dotenv.destroy(env) + + testing.expect(t, env["OK"] == "yep") + testing.expect( + t, + "not-an-assignment" not_in env, + "line without '=' should be skipped", + ) +} + +@(test) +test_parse_missing_file :: proc(t: ^testing.T) { + env, ok := dotenv.parse_file("/nonexistent/dotenv_test_does_not_exist.env") + testing.expect(t, !ok, "missing file should report failure") + testing.expect(t, env == nil, "missing file should return nil map") +} + +@(test) +test_real_env_overrides_file :: proc(t: ^testing.T) { + testing.expect(t, os.set_env("DESI_EXPLORER_TEST_FOO", "from_env") == nil) + defer os.unset_env("DESI_EXPLORER_TEST_FOO") + + env := load_env(t, "DESI_EXPLORER_TEST_FOO=from_file\n") + defer dotenv.destroy(env) + + testing.expect( + t, + env["DESI_EXPLORER_TEST_FOO"] == "from_env", + "real env var should win over file", + ) +} + +@(test) +test_decode_maps_fields_case_insensitively :: proc(t: ^testing.T) { + env := load_env( + t, + "API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\nRATIO=0.5\n", + ) + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://127.0.0.1:8080", + "API_URL maps onto api_url", + ) + testing.expect(t, cfg.debug == true, "DEBUG=true should decode to true") + testing.expect(t, cfg.port == 8080, "PORT=8080 should decode to int 8080") + testing.expect(t, cfg.ratio == 0.5, "RATIO=0.5 should decode to f64 0.5") +} + +@(test) +test_decode_matches_exact_and_lowercase_keys :: proc(t: ^testing.T) { + env := load_env(t, "api_url=http://exact\nPort=9090\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://exact", + "exact-case key should match", + ) + testing.expect(t, cfg.port == 9090, "mixed-case key should match field") +} + +@(test) +test_decode_missing_keys_leave_zero_values :: proc(t: ^testing.T) { + env := load_env(t, "UNRELATED=value\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + + testing.expect(t, cfg.api_url == "") + testing.expect(t, !cfg.debug) + testing.expect(t, cfg.port == 0) + testing.expect(t, cfg.ratio == 0) +} + +@(test) +test_decode_unparsable_int_fails :: proc(t: ^testing.T) { + env := load_env(t, "PORT=oops\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect( + t, + !dotenv.decode(env, &cfg), + "unparsable int should make decode fail", + ) +} + +@(test) +test_decode_unparsable_bool_fails :: proc(t: ^testing.T) { + env := load_env(t, "DEBUG=maybe\nAPI_URL=http://127.0.0.1:8080\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect( + t, + !dotenv.decode(env, &cfg), + "unparsable bool should make decode fail", + ) + defer delete(cfg.api_url) +} + +@(test) +test_decode_hex_and_negative_ints :: proc(t: ^testing.T) { + env := load_env(t, "PORT=0x1F\n") + defer dotenv.destroy(env) + + cfg := Test_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + testing.expect(t, cfg.port == 31, "hex int should decode") +} diff --git a/gui/src/config.odin b/gui/src/config.odin index f019d99..b5caa65 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -1,18 +1,26 @@ package main -import "lib:dotenv" +import "core:os" +import dotenv "lib:dotenv/src" Config :: struct { api_url: string, } -get_config :: proc() -> (^Config, ^Error) { +get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { env, _ := dotenv.parse_file(".env", context.temp_allocator) defer dotenv.destroy(env) c := new(Config) - c.api_url = dotenv.get_string(env, "API_URL") + if env == nil { + c.api_url = os.get_env("API_URL", context.temp_allocator) + } else if !dotenv.decode(env, c) { + err := new(Error) + err.type = .Config + err.message = "failed to decode .env into Config" + return nil, err + } if err := validate_config(c); err != nil { return nil, err @@ -26,4 +34,4 @@ validate_config :: proc(c: ^Config) -> ^Error { if c.api_url == "" do err.type = .Config; err.message = "'API_URL' is required" return err -} \ No newline at end of file +} diff --git a/gui/test/dotenv_test.odin b/gui/test/dotenv_test.odin deleted file mode 100644 index d7ba8f6..0000000 --- a/gui/test/dotenv_test.odin +++ /dev/null @@ -1,87 +0,0 @@ -package dotenv_tests - -import "core:os" -import "core:testing" -import "lib:dotenv" - -@(test) -test_parse_basic :: proc(t: ^testing.T) { - env := dotenv.parse("API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n") - defer dotenv.destroy(env) - - testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080") - testing.expect(t, env["DEBUG"] == "true") - testing.expect(t, env["PORT"] == "8080") -} - -@(test) -test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) { - env := dotenv.parse("# leading comment\n\n \nFOO=bar \nBAZ = qux \n") - defer dotenv.destroy(env) - - testing.expect(t, env["FOO"] == "bar", "value should be trimmed") - testing.expect(t, env["BAZ"] == "qux", "key and value should be trimmed around '='") - testing.expect(t, "API_URL" not_in env, "comment-only lines should not be parsed") -} - -@(test) -test_parse_quoted_values :: proc(t: ^testing.T) { - env := dotenv.parse("GREETING=\"hello world\"\nEMPTY=\"\"\n") - defer dotenv.destroy(env) - - testing.expect(t, env["GREETING"] == "hello world", "quoted value with inner space") - testing.expect(t, env["EMPTY"] == "", "double-quoted empty value") -} - -@(test) -test_parse_skips_lines_without_equals :: proc(t: ^testing.T) { - env := dotenv.parse("not-an-assignment\nOK=yep\n") - defer dotenv.destroy(env) - - testing.expect(t, env["OK"] == "yep") - testing.expect(t, "not-an-assignment" not_in env, "line without '=' should be skipped") -} - -@(test) -test_get_string_falls_back_to_file :: proc(t: ^testing.T) { - env := dotenv.parse("FOO=from_file\n") - defer dotenv.destroy(env) - - testing.expect(t, dotenv.get_string(env, "FOO") == "from_file", "missing env var should use file value") - testing.expect(t, dotenv.get_string(env, "MISSING") == "", "absent everywhere should be empty") -} - -@(test) -test_get_string_prefers_real_env :: proc(t: ^testing.T) { - env := dotenv.parse("FOO=from_file\n") - defer dotenv.destroy(env) - - testing.expect(t, os.set_env("FOO", "from_env") == nil) - defer os.unset_env("FOO") - - testing.expect(t, dotenv.get_string(env, "FOO") == "from_env", "real env var should win over file") -} - -@(test) -test_get_bool :: proc(t: ^testing.T) { - env := dotenv.parse("A=true\nB=0\nC=FALSE\nD=garbage\n") - defer dotenv.destroy(env) - - testing.expect(t, dotenv.get_bool(env, "A", false), "\"true\" should parse to true") - testing.expect(t, !dotenv.get_bool(env, "B", true), "\"0\" should parse to false") - testing.expect(t, !dotenv.get_bool(env, "C", true), "\"FALSE\" should parse to false") - testing.expect(t, dotenv.get_bool(env, "D", true), "unparsable value should fall back to default") - testing.expect(t, dotenv.get_bool(env, "MISSING", true), "missing key should fall back to default") -} - -@(test) -test_get_int :: proc(t: ^testing.T) { - env := dotenv.parse("PORT=8080\nNEG=-42\nHEX=0x1F\nGARBAGE=abc\n") - defer dotenv.destroy(env) - - testing.expect(t, dotenv.get_int(env, "PORT", -1) == 8080, "decimal int") - testing.expect(t, dotenv.get_int(env, "NEG", 0) == -42, "negative int") - testing.expect(t, dotenv.get_int(env, "HEX", 0) == 31, "hex int") - testing.expect(t, dotenv.get_int(env, "GARBAGE", 7) == 7, "unparsable value should fall back to default") - testing.expect(t, dotenv.get_int(env, "MISSING", 7) == 7, "missing key should fall back to default") -} \ No newline at end of file -- 2.52.0 From 1443369f6c0704a17a0dd140e8fba79f32fd22a9 Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Mon, 7 Sep 2026 14:55:49 -0600 Subject: [PATCH 7/9] add dev resources; plumb GUI_ENV_FILE/API_ENV_FILE/API_DESI_DATA into run targets --- Makefile | 16 ++- README.md | 16 +++ api/Cargo.lock | 7 ++ api/Cargo.toml | 9 +- api/src/config.rs | 15 ++- api/src/lib.rs | 1 + api/src/main.rs | 42 +++++++- api/src/models.rs | 8 +- api/src/routes/catalogs.rs | 54 +++++----- api/src/routes/mod.rs | 14 ++- api/src/store.rs | 110 +++++++++++++++++++++ api/tests/objects.rs | 93 +++++++++++++++++ gui/lib/local/dotenv/src/dotenv.odin | 32 ++++-- gui/lib/local/dotenv/test/dotenv_test.odin | 44 +++++++++ gui/src/config.odin | 14 ++- resources/dev/api.env.example | 8 ++ resources/dev/desi_subset.json | 58 +++++++++++ resources/dev/gui.env.example | 7 ++ 18 files changed, 490 insertions(+), 58 deletions(-) create mode 100644 api/src/store.rs create mode 100644 api/tests/objects.rs create mode 100644 resources/dev/api.env.example create mode 100644 resources/dev/desi_subset.json create mode 100644 resources/dev/gui.env.example diff --git a/Makefile b/Makefile index f4c2f0b..064264a 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,14 @@ GUI := gui API := api INFRA := infra +# Local dev/test assets live under resources/dev. These are the defaults for +# the run/*-web targets; override any of them on the command line, e.g. +# make run GUI_ENV_FILE=/path/to/gui.env API_DESI_DATA=/path/to/data.json +RESOURCE_DIR := $(CURDIR)/resources/dev +GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example +API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example +API_DESI_DATA ?= $(RESOURCE_DIR)/desi_subset.json + .PHONY: help setup run run-web build build-debug build-web test clean fmt \ renovate-validate @@ -32,9 +40,9 @@ setup: ## Setup all sub-projects (submodules, gui deps, api deps, infra deps) ## ---- Renderer (Odin) ----------------------------------------------------- run: ## Run the native app (gui/) - @$(MAKE) -C $(API) run & api_pid=$$!; \ + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ - $(MAKE) -C $(GUI) run; \ + GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \ kill $$api_pid 2>/dev/null build: ## Release build (gui/ + api/) @@ -49,9 +57,9 @@ build-web: ## WebAssembly build -> build/web (gui/, needs emscripten) @$(MAKE) -C $(GUI) build-web run-web: ## Start WASM build + API server for web dev - @$(MAKE) -C $(API) run & api_pid=$$!; \ + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ - $(MAKE) -C $(GUI) build-web; \ + GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \ kill $$api_pid 2>/dev/null ## ---- Aggregates ---------------------------------------------------------- diff --git a/README.md b/README.md index fe21849..af603a9 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,22 @@ make clean # remove build artifacts from all projects make fmt # format all projects ``` +`make run` / `make run-web` configure the API and renderer from the example +assets under `resources/dev/` (see the dotenv lib in `gui/lib/local/dotenv`). +Override any of them on the command line: + +```sh +make run \ + GUI_ENV_FILE=/path/to/gui.env \ + API_ENV_FILE=/path/to/api.env \ + API_DESI_DATA=/path/to/desi_data.json +``` + +The defaults point at `resources/dev/gui.env.example` (renderer's `API_URL`), +`resources/dev/api.env.example` (API `API_BIND_ADDR`), and +`resources/dev/desi_subset.json` (a small JSON catalog subset served by the +API's `/api/v1/catalogs` and `/api/v1/objects` endpoints). + Project-specific targets live in their own `Makefile` and are reached with `make -C `: diff --git a/api/Cargo.lock b/api/Cargo.lock index 3c2a245..c3b08b5 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -99,6 +99,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "dotenvy", "serde", "serde_json", "tokio", @@ -108,6 +109,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "errno" version = "0.3.14" diff --git a/api/Cargo.toml b/api/Cargo.toml index 7dfe638..aff779a 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,17 +5,18 @@ edition = "2021" description = "Backend API for DESI Explorer — serves DESI survey catalog data" [dependencies] +anyhow = "1" axum = "0.8" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +dotenvy = "0.15" serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tower-http = { version = "0.7", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tower-http = { version = "0.7", features = ["cors", "trace"] } -anyhow = "1" [dev-dependencies] tower = { version = "0.5", features = ["util"] } -serde_json = "1" [profile.release] lto = true diff --git a/api/src/config.rs b/api/src/config.rs index e168014..85141ef 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -1,5 +1,10 @@ +use std::path::PathBuf; + pub struct Config { pub bind_addr: String, + /// Path to a DESI data file (JSON) to serve; `None` falls back to the + /// built-in placeholder catalogs. + pub desi_data: Option, } impl Config { @@ -7,6 +12,14 @@ impl Config { let bind_addr = std::env::var("API_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); - Ok(Self { bind_addr }) + let desi_data = std::env::var("API_DESI_DATA") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from); + + Ok(Self { + bind_addr, + desi_data, + }) } } diff --git a/api/src/lib.rs b/api/src/lib.rs index b77fb25..2530105 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,3 +1,4 @@ pub mod config; pub mod models; pub mod routes; +pub mod store; diff --git a/api/src/main.rs b/api/src/main.rs index fef0a0c..20261c2 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,4 +1,7 @@ -use desi_explorer_api::{config, routes}; +use std::path::Path; +use std::sync::Arc; + +use desi_explorer_api::{config, routes, store}; use tracing_subscriber::EnvFilter; @@ -11,8 +14,22 @@ async fn main() -> anyhow::Result<()> { ) .init(); + load_env_file()?; + let config = config::Config::from_env()?; - let app = routes::app(); + + let catalog_store = match &config.desi_data { + Some(path) => { + tracing::info!(path = %path.display(), "loading DESI data"); + store::CatalogStore::load(Path::new(path))? + } + None => { + tracing::warn!("API_DESI_DATA not set, serving placeholder catalogs"); + store::CatalogStore::placeholder() + } + }; + + let app = routes::app_with_state(Arc::new(catalog_store)); let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?; tracing::info!("DESI Explorer API listening on {}", config.bind_addr); @@ -24,6 +41,27 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Loads the env file named by `API_ENV_FILE` (if set) into the process +/// environment. Existing env vars are not overridden, so values passed +/// directly on the command line or by the Makefile take precedence. +fn load_env_file() -> anyhow::Result<()> { + let path = std::env::var("API_ENV_FILE").unwrap_or_default(); + if path.is_empty() { + return Ok(()); + } + + match dotenvy::from_path(&path) { + Ok(_) => tracing::info!(%path, "loaded env file"), + Err(err) => { + return Err(anyhow::anyhow!( + "failed to load API_ENV_FILE {path:?}: {err}" + )) + } + } + + Ok(()) +} + async fn shutdown_signal() { let _ = tokio::signal::ctrl_c().await; tracing::info!("shutting down"); diff --git a/api/src/models.rs b/api/src/models.rs index 0090bcd..acdd6a6 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -1,17 +1,17 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; /// Catalog metadata for a DESI data release/survey. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Catalog { pub name: String, pub release: String, - pub description: &'static str, + pub description: String, pub object_count: Option, } /// A single catalog object (galaxy / quasar / star) with its survey /// coordinates. `ra` and `dec` are in degrees; `redshift` is dimensionless. -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CatalogObject { pub id: String, pub catalog: String, diff --git a/api/src/routes/catalogs.rs b/api/src/routes/catalogs.rs index 5e5d220..c6fbc7d 100644 --- a/api/src/routes/catalogs.rs +++ b/api/src/routes/catalogs.rs @@ -1,34 +1,16 @@ +use std::sync::Arc; + use axum::{ - extract::Query, - http::StatusCode, - response::{IntoResponse, Response}, + extract::{Query, State}, Json, }; use serde::Deserialize; -use std::sync::LazyLock; use crate::models::{Catalog, CatalogObject}; +use crate::store::CatalogStore; -/// Placeholder catalogs until real DESI EDR/DR1 ingestion lands. -static CATALOGS: LazyLock> = LazyLock::new(|| { - vec![ - Catalog { - name: "edr".to_string(), - release: "EDR".to_string(), - description: "DESI Early Data Release", - object_count: None, - }, - Catalog { - name: "dr1".to_string(), - release: "DR1".to_string(), - description: "DESI Data Release 1", - object_count: None, - }, - ] -}); - -pub async fn list_catalogs() -> Json> { - Json(CATALOGS.clone()) +pub async fn list_catalogs(State(state): State>) -> Json> { + Json(state.catalogs.clone()) } #[derive(Debug, Deserialize)] @@ -38,17 +20,29 @@ pub struct ObjectQuery { limit: Option, } -/// Placeholder object query. Real implementation will page through the -/// centralized DESI catalog store rather than return an empty result set. -pub async fn list_objects(Query(query): Query) -> Response { +pub async fn list_objects( + State(state): State>, + Query(query): Query, +) -> Json> { let limit = query.limit.unwrap_or(100).min(10_000); tracing::debug!( %limit, catalog = query.catalog.as_deref().unwrap_or("all"), - "querying catalog objects (placeholder)" + objects = state.objects.len(), + "querying catalog objects" ); - let objects: Vec = Vec::new(); - (StatusCode::OK, Json(objects)).into_response() + let objects: Vec = match &query.catalog { + Some(catalog) => state + .objects + .iter() + .filter(|o| &o.catalog == catalog) + .take(limit) + .cloned() + .collect(), + None => state.objects.iter().take(limit).cloned().collect(), + }; + + Json(objects) } diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 2a7119f..6ec05d1 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1,13 +1,23 @@ pub mod catalogs; pub mod health; +use std::sync::Arc; + use axum::{routing::get, Router}; -/// Builds the application router. Kept separate from `main` so tests can -/// construct it without binding a socket. +use crate::store::CatalogStore; + +/// Builds the application router with a static placeholder store. Kept +/// separate from `main` so tests can construct it without binding a socket. pub fn app() -> Router { + app_with_state(Arc::new(CatalogStore::placeholder())) +} + +/// Builds the application router serving the given catalog store. +pub fn app_with_state(state: Arc) -> Router { Router::new() .route("/health", get(health::health)) .route("/api/v1/catalogs", get(catalogs::list_catalogs)) .route("/api/v1/objects", get(catalogs::list_objects)) + .with_state(state) } diff --git a/api/src/store.rs b/api/src/store.rs new file mode 100644 index 0000000..8db1fa7 --- /dev/null +++ b/api/src/store.rs @@ -0,0 +1,110 @@ +use std::path::Path; + +use serde::Deserialize; + +use crate::models::{Catalog, CatalogObject}; + +/// In-memory catalog store, shared (via `Arc`) across routes. Populated either +/// from a DESI data file loaded at startup or from `placeholder`. +#[derive(Debug, Clone, Default)] +pub struct CatalogStore { + pub catalogs: Vec, + pub objects: Vec, +} + +/// JSON layout of the DESI data file referenced by `API_DESI_DATA`. +#[derive(Debug, Deserialize)] +pub struct DataFile { + pub catalogs: Vec, + #[serde(default)] + pub objects: Vec, +} + +impl CatalogStore { + /// Static fallback catalogs used when no `API_DESI_DATA` file is given + /// (and by the `routes::app()` test helper). + pub fn placeholder() -> Self { + Self { + catalogs: vec![ + Catalog { + name: "edr".to_string(), + release: "EDR".to_string(), + description: "DESI Early Data Release".to_string(), + object_count: None, + }, + Catalog { + name: "dr1".to_string(), + release: "DR1".to_string(), + description: "DESI Data Release 1".to_string(), + object_count: None, + }, + ], + objects: Vec::new(), + } + } + + /// Loads catalogs and objects from a JSON data file. Errors on unreadable + /// files or malformed JSON so the caller can fail loudly instead of + /// silently serving empty data. + pub fn load(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path)?; + let file: DataFile = serde_json::from_str(&text)?; + Ok(Self { + catalogs: file.catalogs, + objects: file.objects, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_parses_catalogs_and_objects() { + let json = r#"{ + "catalogs": [ + {"name":"edr","release":"EDR","description":"test","object_count":2} + ], + "objects": [ + {"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8} + ] + }"#; + + let dir = std::env::temp_dir().join(format!( + "desi_explorer_store_{}_{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, json).unwrap(); + + let store = CatalogStore::load(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(store.catalogs.len(), 1); + assert_eq!(store.catalogs[0].name, "edr"); + assert_eq!(store.catalogs[0].object_count, Some(2)); + assert_eq!(store.objects.len(), 1); + assert_eq!(store.objects[0].id, "o1"); + assert_eq!(store.objects[0].ra, 1.5); + } + + #[test] + fn load_rejects_malformed_json() { + let dir = std::env::temp_dir().join(format!( + "desi_explorer_store_{}_{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, "not json").unwrap(); + + let result = CatalogStore::load(&path); + let _ = std::fs::remove_dir_all(&dir); + + assert!(result.is_err()); + } +} diff --git a/api/tests/objects.rs b/api/tests/objects.rs new file mode 100644 index 0000000..d0a682d --- /dev/null +++ b/api/tests/objects.rs @@ -0,0 +1,93 @@ +use axum::body::{to_bytes, Body}; +use axum::http::{Request, StatusCode}; +use std::sync::Arc; +use tower::ServiceExt; + +use desi_explorer_api::models::CatalogObject; +use desi_explorer_api::routes; +use desi_explorer_api::store::CatalogStore; + +fn sample_store() -> CatalogStore { + CatalogStore { + catalogs: Vec::new(), + objects: vec![ + CatalogObject { + id: "o1".to_string(), + catalog: "edr".to_string(), + object_type: "GALAXY".to_string(), + ra: 1.5, + dec: 2.5, + redshift: 0.8, + }, + CatalogObject { + id: "o2".to_string(), + catalog: "dr1".to_string(), + object_type: "STAR".to_string(), + ra: 3.5, + dec: 4.5, + redshift: 0.0, + }, + ], + } +} + +#[tokio::test] +async fn objects_returns_all_when_no_filter() { + let app = routes::app_with_state(Arc::new(sample_store())); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/objects") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let objects: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(objects.len(), 2); +} + +#[tokio::test] +async fn objects_filters_by_catalog() { + let app = routes::app_with_state(Arc::new(sample_store())); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/objects?catalog=edr") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let objects: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].id, "o1"); +} + +#[tokio::test] +async fn objects_respects_limit() { + let app = routes::app_with_state(Arc::new(sample_store())); + + let response = app + .oneshot( + Request::builder() + .uri("/api/v1/objects?limit=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let objects: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(objects.len(), 1); +} diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin index ebfccb8..9480bef 100644 --- a/gui/lib/local/dotenv/src/dotenv.odin +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -80,13 +80,26 @@ destroy :: proc(env: map[string]string) { delete(env) } +// env_key returns the env key that should bind to a struct field. It prefers +// an explicit `env:"NAME"` tag; when the tag is absent or empty it falls +// back to the field's name. Matching against the parsed map is +// case-insensitive, so API_URL maps onto api_url (or an `env:"API_URL"` tag). +@(private) +env_key_for_field :: proc(field: reflect.Struct_Field) -> string { + if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); + ok && tag_key != "" { + return tag_key + } + return field.name +} + // decode populates dest's fields from env, matching each field by name -// (case-insensitively, so API_URL maps onto api_url). Values are converted -// to the field's type: string is cloned as-is into allocator, integers are -// parsed with strconv.parse_int (decimal/hex/negative), booleans with -// strconv.parse_bool, and floats with strconv.parse_f64. Keys missing from -// env leave the field at its zero value. It returns false if a present value -// cannot be converted to the field's type. +// (or by an `env:"NAME"` struct tag). Values are converted to the field's +// type: string is cloned as-is into allocator, integers are parsed with +// strconv.parse_int (decimal/hex/negative), booleans with strconv.parse_bool, +// and floats with strconv.parse_f64. Keys missing from env leave the field +// at its zero value. It returns false if a present value cannot be converted +// to the field's type. decode :: proc( env: map[string]string, dest: ^$T, @@ -98,11 +111,12 @@ decode :: proc( return false } - name: string value: string field_ptr := rawptr(dest) + st: reflect.Struct_Field for _, i in fields.names[:fields.field_count] { - name = fields.names[i] + st = reflect.struct_field_at(T, i) + name := env_key_for_field(st) value = "" found := false for key, v in env { @@ -157,7 +171,7 @@ decode :: proc( return false } case: - // unsupported field type (slices, pointers, ...) is left untouched + // unsupported field type (slices, pointers, ...) is left untouched } } diff --git a/gui/lib/local/dotenv/test/dotenv_test.odin b/gui/lib/local/dotenv/test/dotenv_test.odin index e0f17fa..b14fc73 100644 --- a/gui/lib/local/dotenv/test/dotenv_test.odin +++ b/gui/lib/local/dotenv/test/dotenv_test.odin @@ -12,6 +12,16 @@ Test_Config :: struct { ratio: f64, } +Tagged_Config :: struct { + api_url: string `env:"API_URL"`, + port: int `env:"PORT"`, + debug: bool `env:"DEBUG"`, +} + +Tagged_Empty :: struct { + api_url: string `env:""`, +} + // load_env writes src to a unique temp file and parses it via parse_file. // The returned map owns its strings; callers must destroy it. load_env :: proc(t: ^testing.T, src: string) -> map[string]string { @@ -199,3 +209,37 @@ test_decode_hex_and_negative_ints :: proc(t: ^testing.T) { testing.expect(t, dotenv.decode(env, &cfg)) testing.expect(t, cfg.port == 31, "hex int should decode") } + +@(test) +test_decode_honors_env_tags :: proc(t: ^testing.T) { + env := load_env(t, "API_URL=http://127.0.0.1:8080\nPORT=9090\nDEBUG=true\n") + defer dotenv.destroy(env) + + cfg := Tagged_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://127.0.0.1:8080", + "env tag should bind API_URL", + ) + testing.expect(t, cfg.port == 9090, "env tag should bind PORT") + testing.expect(t, cfg.debug == true, "env tag should bind DEBUG") +} + +@(test) +test_decode_empty_env_tag_falls_back_to_field_name :: proc(t: ^testing.T) { + env := load_env(t, "api_url=http://fallback\n") + defer dotenv.destroy(env) + + cfg := Tagged_Empty{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://fallback", + "empty env tag should use field name", + ) +} diff --git a/gui/src/config.odin b/gui/src/config.odin index b5caa65..b6f06b7 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -4,11 +4,21 @@ import "core:os" import dotenv "lib:dotenv/src" Config :: struct { - api_url: string, + api_url: string `env:"API_URL"`, } get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { - env, _ := dotenv.parse_file(".env", context.temp_allocator) + path := ".env" + if env_file != nil && env_file^ != "" { + path = env_file^ + } else if from_env, ok := os.lookup_env( + "GUI_ENV_FILE", + context.temp_allocator, + ); ok { + path = from_env + } + + env, _ := dotenv.parse_file(path, context.temp_allocator) defer dotenv.destroy(env) c := new(Config) diff --git a/resources/dev/api.env.example b/resources/dev/api.env.example new file mode 100644 index 0000000..4101f60 --- /dev/null +++ b/resources/dev/api.env.example @@ -0,0 +1,8 @@ +# Example API environment — passed via API_ENV_FILE (see root Makefile). +# +# The API also reads API_DESI_DATA from the environment (set by the root +# Makefile to resources/dev/desi_subset.json by default), so it is not +# repeated here. Values here win unless the same key is already set in the +# real process environment. + +API_BIND_ADDR=127.0.0.1:8080 \ No newline at end of file diff --git a/resources/dev/desi_subset.json b/resources/dev/desi_subset.json new file mode 100644 index 0000000..3df3715 --- /dev/null +++ b/resources/dev/desi_subset.json @@ -0,0 +1,58 @@ +{ + "catalogs": [ + { + "name": "edr", + "release": "EDR", + "description": "DESI Early Data Release (local dev subset)", + "object_count": 3 + }, + { + "name": "dr1", + "release": "DR1", + "description": "DESI Data Release 1 (local dev subset)", + "object_count": 2 + } + ], + "objects": [ + { + "id": "DESI_EDR_000000001", + "catalog": "edr", + "object_type": "GALAXY", + "ra": 150.123456, + "dec": 2.345678, + "redshift": 0.5521 + }, + { + "id": "DESI_EDR_000000002", + "catalog": "edr", + "object_type": "GALAXY", + "ra": 254.987654, + "dec": -15.203041, + "redshift": 1.1045 + }, + { + "id": "DESI_EDR_000000003", + "catalog": "edr", + "object_type": "QSO", + "ra": 75.001234, + "dec": 38.765432, + "redshift": 2.8756 + }, + { + "id": "DESI_DR1_000000001", + "catalog": "dr1", + "object_type": "STAR", + "ra": 188.556677, + "dec": 47.112233, + "redshift": 0.0001 + }, + { + "id": "DESI_DR1_000000002", + "catalog": "dr1", + "object_type": "GALAXY", + "ra": 300.445566, + "dec": 12.778899, + "redshift": 0.7742 + } + ] +} \ No newline at end of file diff --git a/resources/dev/gui.env.example b/resources/dev/gui.env.example new file mode 100644 index 0000000..7d1dabc --- /dev/null +++ b/resources/dev/gui.env.example @@ -0,0 +1,7 @@ +# Example GUI environment — passed via GUI_ENV_FILE (see root Makefile). +# +# Point the renderer at the local dev API: `make run` also starts the API +# server, so http://127.0.0.1:8080 is the default. Values here win unless +# the same key is already set in the real process environment. + +API_URL=http://127.0.0.1:8080 \ No newline at end of file -- 2.52.0 From fade4a17dca233b8e287abc3e2e88ebd4f6c997f Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Mon, 7 Sep 2026 19:51:22 -0600 Subject: [PATCH 8/9] additional changes --- .gitignore | 1 + api/Makefile | 14 ++++++++ api/src/store.rs | 53 --------------------------- api/tests/store.rs | 54 ++++++++++++++++++++++++++++ gui/Makefile | 20 ++++++++++- gui/lib/local/dotenv/src/dotenv.odin | 24 +++---------- gui/src/config.odin | 13 +++---- gui/src/main.odin | 6 +++- 8 files changed, 103 insertions(+), 82 deletions(-) create mode 100644 api/tests/store.rs diff --git a/.gitignore b/.gitignore index 9dbd8bb..d075350 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ infra/desi-explorer-infra api/target/ .env .env.* +*.env diff --git a/api/Makefile b/api/Makefile index 36d94a7..3de9787 100644 --- a/api/Makefile +++ b/api/Makefile @@ -4,6 +4,20 @@ # `api-*` convenience targets). CARGO ?= cargo +ROOT := .. + +# Normalize API_ENV_FILE / API_DESI_DATA (given relative to the repo root) to +# absolute paths so the API process can open them regardless of its working +# directory. "override" is required because they are usually passed as +# command-line/env vars, which would otherwise override any assignment here. +define normalize_path +ifdef $1 +ifneq ($(abspath $($1)),$($1)) +override $1 := $(abspath $(ROOT)/$($1)) +endif +endif +endef +$(foreach v,API_ENV_FILE API_DESI_DATA,$(eval $(call normalize_path,$v))) .PHONY: help setup run build test check fmt clean diff --git a/api/src/store.rs b/api/src/store.rs index 8db1fa7..9220dcb 100644 --- a/api/src/store.rs +++ b/api/src/store.rs @@ -55,56 +55,3 @@ impl CatalogStore { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn load_parses_catalogs_and_objects() { - let json = r#"{ - "catalogs": [ - {"name":"edr","release":"EDR","description":"test","object_count":2} - ], - "objects": [ - {"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8} - ] - }"#; - - let dir = std::env::temp_dir().join(format!( - "desi_explorer_store_{}_{}", - std::process::id(), - line!() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("data.json"); - std::fs::write(&path, json).unwrap(); - - let store = CatalogStore::load(&path).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - - assert_eq!(store.catalogs.len(), 1); - assert_eq!(store.catalogs[0].name, "edr"); - assert_eq!(store.catalogs[0].object_count, Some(2)); - assert_eq!(store.objects.len(), 1); - assert_eq!(store.objects[0].id, "o1"); - assert_eq!(store.objects[0].ra, 1.5); - } - - #[test] - fn load_rejects_malformed_json() { - let dir = std::env::temp_dir().join(format!( - "desi_explorer_store_{}_{}", - std::process::id(), - line!() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("data.json"); - std::fs::write(&path, "not json").unwrap(); - - let result = CatalogStore::load(&path); - let _ = std::fs::remove_dir_all(&dir); - - assert!(result.is_err()); - } -} diff --git a/api/tests/store.rs b/api/tests/store.rs new file mode 100644 index 0000000..4954aaa --- /dev/null +++ b/api/tests/store.rs @@ -0,0 +1,54 @@ +use desi_explorer_api::store::CatalogStore; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_parses_catalogs_and_objects() { + let json = r#"{ + "catalogs": [ + {"name":"edr","release":"EDR","description":"test","object_count":2} + ], + "objects": [ + {"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8} + ] + }"#; + + let dir = std::env::temp_dir().join(format!( + "desi_explorer_store_{}_{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, json).unwrap(); + + let store = CatalogStore::load(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(store.catalogs.len(), 1); + assert_eq!(store.catalogs[0].name, "edr"); + assert_eq!(store.catalogs[0].object_count, Some(2)); + assert_eq!(store.objects.len(), 1); + assert_eq!(store.objects[0].id, "o1"); + assert_eq!(store.objects[0].ra, 1.5); + } + + #[test] + fn load_rejects_malformed_json() { + let dir = std::env::temp_dir().join(format!( + "desi_explorer_store_{}_{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.json"); + std::fs::write(&path, "not json").unwrap(); + + let result = CatalogStore::load(&path); + let _ = std::fs::remove_dir_all(&dir); + + assert!(result.is_err()); + } +} diff --git a/gui/Makefile b/gui/Makefile index 94dca55..a0c71a9 100644 --- a/gui/Makefile +++ b/gui/Makefile @@ -8,13 +8,28 @@ # output dirs live at the repo root and are referenced through `$(ROOT)`. ODIN ?= odin +GDB ?= gdb ROOT := .. BIN := $(ROOT)/bin BINARY := $(BIN)/desi_explorer ODIN_FLAGS := -collection:lib=lib/local WASM_DEFINE := RAYLIB_WASM_LIB=env.o -.PHONY: help setup add-dep run build build-debug build-web test clean fmt +# Normalize GUI_ENV_FILE (given relative to the repo root) to an absolute path +# so the Odin process can open it regardless of its working directory. +# "override" is required because GUI_ENV_FILE is usually set on the command +# line (or passed as an env var to this sub-make), which would otherwise +# override any assignment made here. +ifdef GUI_ENV_FILE +ifneq ($(abspath $(GUI_ENV_FILE)),$(GUI_ENV_FILE)) +override GUI_ENV_FILE := $(abspath $(ROOT)/$(GUI_ENV_FILE)) +endif +endif +# "override" on a command-line variable silently drops it from the recipe +# environment; re-export it so the app can find its env file (run + gdb). +export GUI_ENV_FILE + +.PHONY: help setup add-dep run build build-debug gdb build-web test clean fmt help: ## List available targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @@ -41,6 +56,9 @@ build-debug: ## Debug build -> bin/desi_explorer @mkdir -p lib/local $(BIN) $(ODIN) build src $(ODIN_FLAGS) -o:none -debug -out:$(BINARY) +gdb: build-debug ## Run the native app under gdb (type 'run', then 'bt' on a crash) + $(GDB) -q --args $(BINARY) $(ARGS) + build-web: ## WebAssembly build -> build/web (needs emscripten) @scripts/build_web.sh diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin index 9480bef..047da75 100644 --- a/gui/lib/local/dotenv/src/dotenv.odin +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -12,10 +12,7 @@ import "core:strings" // double quotes. Real process environment variables take precedence over the // file. The returned map owns its keys/values; release it with destroy. @(private) -parse :: proc( - src: string, - allocator := context.allocator, -) -> map[string]string { +parse :: proc(src: string, allocator := context.allocator) -> map[string]string { result := make(map[string]string, allocator) it := src @@ -54,18 +51,12 @@ parse :: proc( // parse_file reads a dotenv file from disk and parses it into a map. It // returns (nil, false) when the file cannot be read (e.g. it does not exist). -parse_file :: proc( - filename: string, - allocator := context.allocator, -) -> ( - map[string]string, - bool, -) { +parse_file :: proc(filename: string, allocator := context.allocator) -> (map[string]string, bool) { data, err := os.read_entire_file(filename, allocator) if err != nil { return nil, false } - defer delete(data) + defer delete(data, allocator) return parse(string(data), allocator), true } @@ -86,8 +77,7 @@ destroy :: proc(env: map[string]string) { // case-insensitive, so API_URL maps onto api_url (or an `env:"API_URL"` tag). @(private) env_key_for_field :: proc(field: reflect.Struct_Field) -> string { - if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); - ok && tag_key != "" { + if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); ok && tag_key != "" { return tag_key } return field.name @@ -100,11 +90,7 @@ env_key_for_field :: proc(field: reflect.Struct_Field) -> string { // and floats with strconv.parse_f64. Keys missing from env leave the field // at its zero value. It returns false if a present value cannot be converted // to the field's type. -decode :: proc( - env: map[string]string, - dest: ^$T, - allocator := context.allocator, -) -> bool { +decode :: proc(env: map[string]string, dest: ^$T, allocator := context.allocator) -> bool { ti := reflect.type_info_base(type_info_of(T)) fields, ok := ti.variant.(runtime.Type_Info_Struct) if !ok { diff --git a/gui/src/config.odin b/gui/src/config.odin index b6f06b7..1aa9ea4 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -11,10 +11,7 @@ get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { path := ".env" if env_file != nil && env_file^ != "" { path = env_file^ - } else if from_env, ok := os.lookup_env( - "GUI_ENV_FILE", - context.temp_allocator, - ); ok { + } else if from_env, ok := os.lookup_env("GUI_ENV_FILE", context.temp_allocator); ok { path = from_env } @@ -39,9 +36,9 @@ get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { return c, nil } -validate_config :: proc(c: ^Config) -> ^Error { - err := new(Error) - if c.api_url == "" do err.type = .Config; err.message = "'API_URL' is required" - +validate_config :: proc(c: ^Config) -> (err: ^Error) { + if c.api_url == "" { + err = &Error{.Config, "'API_URL' is required"} + } return err } diff --git a/gui/src/main.odin b/gui/src/main.odin index 315b1cb..06213d8 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -1,7 +1,9 @@ package main +import "base:runtime" import "core:math" import "core:math/rand" +import "core:os" import rl "vendor:raylib" WIDTH :: 1280 @@ -27,7 +29,9 @@ main :: proc() { c: ^Config - if c, err := get_config(); err != nil { + s := os.get_env("GUI_ENV_FILE", context.temp_allocator) + + if c, err := get_config(&s); err != nil { panic(format_error(err)) } -- 2.52.0 From 3a037b5fd3641a92f5f8a3df9b544e3989b4d68b Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Tue, 8 Sep 2026 01:09:55 -0600 Subject: [PATCH 9/9] got dotenv parsing working --- gui/lib/local/dotenv/src/dotenv.odin | 12 +++++++----- gui/src/config.odin | 12 ++++++------ gui/src/data.odin | 7 +++++++ gui/src/main.odin | 3 ++- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin index 047da75..524bc3c 100644 --- a/gui/lib/local/dotenv/src/dotenv.odin +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -38,8 +38,9 @@ parse :: proc(src: string, allocator := context.allocator) -> map[string]string } // real process env vars win over the file - if override, found := os.lookup_env(key, context.temp_allocator); found { - value = override + if override, found := os.lookup_env(key, allocator); found { + result[strings.clone(key, allocator)] = override + continue } // clone so the map outlives the source buffer (e.g. a freed file read) @@ -63,10 +64,11 @@ parse_file :: proc(filename: string, allocator := context.allocator) -> (map[str // destroy frees the cloned keys/values and the map itself. Use it to release // a map returned by parse/parse_file (plain delete does not free the strings). -destroy :: proc(env: map[string]string) { +// Any allocator passed to parse/parse_file must be passed here too. +destroy :: proc(env: map[string]string, allocator := context.allocator) { for key, value in env { - delete(key) - delete(value) + delete(key, allocator) + delete(value, allocator) } delete(env) } diff --git a/gui/src/config.odin b/gui/src/config.odin index 1aa9ea4..1a2355b 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -1,5 +1,6 @@ package main +import "core:log" import "core:os" import dotenv "lib:dotenv/src" @@ -15,18 +16,17 @@ get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { path = from_env } + log.debugf("getting config from file: %s", path) + env, _ := dotenv.parse_file(path, context.temp_allocator) - defer dotenv.destroy(env) + defer dotenv.destroy(env, context.temp_allocator) c := new(Config) if env == nil { c.api_url = os.get_env("API_URL", context.temp_allocator) } else if !dotenv.decode(env, c) { - err := new(Error) - err.type = .Config - err.message = "failed to decode .env into Config" - return nil, err + return nil, new_clone(Error{.Config, "failed to decode .env into Config"}) } if err := validate_config(c); err != nil { @@ -38,7 +38,7 @@ get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { validate_config :: proc(c: ^Config) -> (err: ^Error) { if c.api_url == "" { - err = &Error{.Config, "'API_URL' is required"} + err = new_clone(Error{.Config, "'API_URL' is required"}) } return err } diff --git a/gui/src/data.odin b/gui/src/data.odin index e82b36f..e4da099 100644 --- a/gui/src/data.odin +++ b/gui/src/data.odin @@ -1,5 +1,8 @@ package main +import "core:net" +import "vendor:curl" + Catalog :: struct { name: string, release: string, @@ -22,6 +25,10 @@ APIError :: struct { } get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) { + ucurl := curl.url() + + + defer curl.url_cleanup(ucurl) return nil, nil } diff --git a/gui/src/main.odin b/gui/src/main.odin index 06213d8..b971bac 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -28,10 +28,11 @@ rng: rand.Default_Random_State main :: proc() { c: ^Config + err: ^Error s := os.get_env("GUI_ENV_FILE", context.temp_allocator) - if c, err := get_config(&s); err != nil { + if c, err = get_config(&s); err != nil { panic(format_error(err)) } -- 2.52.0