Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a037b5fd3 | |||
| fade4a17dc | |||
| 1443369f6c | |||
| ba24005bfb | |||
| ac2d30510c | |||
| 3803787fe8 | |||
| 8c74efcedb | |||
| 89a41262af | |||
| 772ee0a106 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,3 +7,6 @@ resources/ai/sessions
|
||||
infra/Pulumi.*.yaml.backup
|
||||
infra/desi-explorer-infra
|
||||
api/target/
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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 <dir> <target>`:
|
||||
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+14
-1
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod store;
|
||||
|
||||
+40
-2
@@ -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");
|
||||
|
||||
+4
-4
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
+24
-30
@@ -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<Vec<Catalog>> = 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<Vec<Catalog>> {
|
||||
Json(CATALOGS.clone())
|
||||
pub async fn list_catalogs(State(state): State<Arc<CatalogStore>>) -> Json<Vec<Catalog>> {
|
||||
Json(state.catalogs.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -38,17 +20,29 @@ pub struct ObjectQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<ObjectQuery>) -> Response {
|
||||
pub async fn list_objects(
|
||||
State(state): State<Arc<CatalogStore>>,
|
||||
Query(query): Query<ObjectQuery>,
|
||||
) -> Json<Vec<CatalogObject>> {
|
||||
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<CatalogObject> = Vec::new();
|
||||
(StatusCode::OK, Json(objects)).into_response()
|
||||
let objects: Vec<CatalogObject> = 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)
|
||||
}
|
||||
|
||||
+12
-2
@@ -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<CatalogStore>) -> 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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<Catalog>,
|
||||
pub objects: Vec<CatalogObject>,
|
||||
}
|
||||
|
||||
/// JSON layout of the DESI data file referenced by `API_DESI_DATA`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DataFile {
|
||||
pub catalogs: Vec<Catalog>,
|
||||
#[serde(default)]
|
||||
pub objects: Vec<CatalogObject>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let file: DataFile = serde_json::from_str(&text)?;
|
||||
Ok(Self {
|
||||
catalogs: file.catalogs,
|
||||
objects: file.objects,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<CatalogObject> = 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<CatalogObject> = 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<CatalogObject> = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(objects.len(), 1);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -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,12 +56,22 @@ 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
|
||||
|
||||
test: ## Run Odin unit tests
|
||||
@mkdir -p lib/local
|
||||
$(ODIN) test src $(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
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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, allocator); found {
|
||||
result[strings.clone(key, allocator)] = override
|
||||
continue
|
||||
}
|
||||
|
||||
// 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, allocator)
|
||||
|
||||
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).
|
||||
// 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, allocator)
|
||||
delete(value, allocator)
|
||||
}
|
||||
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
|
||||
// (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, 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
|
||||
}
|
||||
|
||||
value: string
|
||||
field_ptr := rawptr(dest)
|
||||
st: reflect.Struct_Field
|
||||
for _, i in fields.names[:fields.field_count] {
|
||||
st = reflect.struct_field_at(T, i)
|
||||
name := env_key_for_field(st)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
@(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",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import dotenv "lib:dotenv/src"
|
||||
|
||||
Config :: struct {
|
||||
api_url: string `env:"API_URL"`,
|
||||
}
|
||||
|
||||
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 {
|
||||
path = from_env
|
||||
}
|
||||
|
||||
log.debugf("getting config from file: %s", path)
|
||||
|
||||
env, _ := dotenv.parse_file(path, context.temp_allocator)
|
||||
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) {
|
||||
return nil, new_clone(Error{.Config, "failed to decode .env into Config"})
|
||||
}
|
||||
|
||||
if err := validate_config(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
validate_config :: proc(c: ^Config) -> (err: ^Error) {
|
||||
if c.api_url == "" {
|
||||
err = new_clone(Error{.Config, "'API_URL' is required"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import "core:net"
|
||||
import "vendor:curl"
|
||||
|
||||
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(url: string) -> ([dynamic]Catalog, ^APIError) {
|
||||
ucurl := curl.url()
|
||||
|
||||
|
||||
defer curl.url_cleanup(ucurl)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
get_catalog :: proc(url: string, name: string) -> (^Catalog, ^APIError) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
get_catalog_objects :: proc(
|
||||
url: string,
|
||||
catalog_name: string,
|
||||
) -> (
|
||||
[dynamic]CatalogObject,
|
||||
^APIError,
|
||||
) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+13
-7
@@ -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
|
||||
@@ -24,6 +26,16 @@ universe: [dynamic]Galaxy
|
||||
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 {
|
||||
panic(format_error(err))
|
||||
}
|
||||
|
||||
rng = rand.create(0xDE51_0000)
|
||||
context.random_generator = rand.default_random_generator(&rng)
|
||||
|
||||
@@ -103,11 +115,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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=<name>&limit=<n>` → **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<u64>,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["DESI catalog store"]
|
||||
B["Rust / axum API<br/><i>serde JSON models</i>"]
|
||||
C["Odin + raylib GUI<br/><i>hand-mirrored structs<br/>stubs, never used</i>"]
|
||||
A -. "future ingestion" .-> B
|
||||
B --x|"nothing today"| C
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["catalog.fbs<br/><i>single source of truth<br/>checked into repo</i>"]
|
||||
R["flatc --rust"]
|
||||
C["flatcc --c<br/><i>or hand-rolled Odin reader</i>"]
|
||||
API["api/ · Rust"]
|
||||
GUI["gui/ · Odin + raylib"]
|
||||
S --> R --> API
|
||||
S --> C --> GUI
|
||||
API <-->|"HTTP / WebSocket<br/>framed FlatBuffer binary stream"| GUI
|
||||
```
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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<u64>` where
|
||||
> `Some(0)` is meaningful) use **optional scalars** (`default: null`) or wrap the
|
||||
> scalar in a struct.
|
||||
|
||||
## Reading a buffer (conceptual)
|
||||
|
||||
Buffer layout:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BUF["bytes: &[u8]"]
|
||||
O["uoffset<br/>root table offset"]
|
||||
FI["file_identifier<br/>(optional)"]
|
||||
D["tables · vtables · data"]
|
||||
end
|
||||
O --> FI --> D
|
||||
```
|
||||
|
||||
Access sequence — each field is a few offset dereferences and a read:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R["root = follow(bytes)<br/>jump to root table via uoffset"]
|
||||
V["vtable = root − root.vtable_off<br/>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
|
||||
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::<T>(&opts, bytes)` runs the verifier (default
|
||||
options); `root_unchecked::<T>` 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
|
||||
@@ -0,0 +1,279 @@
|
||||
# 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<ObjectQuery>) -> 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<u8>`/
|
||||
`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)
|
||||
|
||||
Build order is **back-to-front** (children before parents):
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["create_string / create_vector<br/>children first"]
|
||||
B["create nested child tables"]
|
||||
C["create parent table<br/>ObjectBatch::create(&args)"]
|
||||
D["builder.finish(root, Some("DESI"))"]
|
||||
E["Bytes::copy_from_slice(fbb.finished_data())<br/>→ 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.
|
||||
- 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<CatalogObject> 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
|
||||
@@ -0,0 +1,235 @@
|
||||
# 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.
|
||||
|
||||
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<br/>hand-rolled, no C dependency"]
|
||||
CGO -- "no" --> PATHA
|
||||
CGO -- "prefer proven lib / less maintenance" --> PATHC["Path A · FFI to FlatCC<br/>bind generated C headers"]
|
||||
PATHC --> REUSE["Path C · OdinArrow reuse<br/>or borrow its decode patterns"]
|
||||
NAT -- "no · browser/WASM" --> PATHD["Path D · TS/JS interop<br/>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` |
|
||||
| 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**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["fetch frame bytes → [dynamic]u8<br/>or a slice pinned for the frame"]
|
||||
B["verify the buffer once"]
|
||||
C["ObjectBatch.ra(&buf) → []f64 view"]
|
||||
D["per object in update()/draw()<br/>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
|
||||
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
|
||||
@@ -0,0 +1,295 @@
|
||||
# 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)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["u32 LE<br/>total buffer len<br/><i>size prefix</i>"]
|
||||
B["u32 LE<br/>root table offset"]
|
||||
C["file identifier<br/>(4 bytes)"]
|
||||
D["tables · vtables · data"]
|
||||
A --> B --> C --> D
|
||||
```
|
||||
|
||||
```rust
|
||||
builder.finish_size_prefixed(root, Some("DESI"));
|
||||
```
|
||||
|
||||
Reader side:
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
let msg = flatbuffers::size_prefixed_root::<ObjectBatch>(&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`)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["u32 LE<br/>message_len"]
|
||||
B["optional checksum<br/>(u32 crc / xxhash)"]
|
||||
C["FlatBuffer payload"]
|
||||
A --> B --> C
|
||||
```
|
||||
|
||||
- `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<Arc<AppState>>) -> 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<AppState>) {
|
||||
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: <base64 16 bytes>
|
||||
// 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<ObjectQuery>) -> Response {
|
||||
let (tx, rx) = mpsc::channel::<flatbuffers::Vec<u8>>(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
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["HTTP GET → one FlatBuffer body per batch<br/>validate Rust builder + Odin reader"]
|
||||
B["WebSocket → one Binary message per batch<br/>interactive path · no custom framing"]
|
||||
C["Self-identifying messages<br/>file_identifier "DESI""]
|
||||
D["size-prefixed / flatstream-style framing<br/>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)
|
||||
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
|
||||
@@ -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::<ServerMessage>(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<u64>` presence trap
|
||||
|
||||
Current Rust model has `object_count: Option<u64>`. 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<u64>`;
|
||||
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<CatalogObject> 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.
|
||||
@@ -0,0 +1,159 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q1{"zero-copy reads<br/>in the per-frame render loop?"}
|
||||
Q1 -- "no" --> PB["Protobuf / gRPC<br/>decode once into draw buffers"]
|
||||
Q1 -- "yes" --> Q2{"truly columnar?<br/>millions of rows"}
|
||||
Q2 -- "yes" --> ARR["Apache Arrow IPC<br/>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
|
||||
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<f64>
|
||||
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
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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<u8> {
|
||||
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. Pipeline:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S["schema/catalog.fbs"]
|
||||
J["testdata/catalog_sample.json"]
|
||||
S --> F["flatc --binary"]
|
||||
J --> F
|
||||
F --> BIN["committed .bin fixtures<br/>repo-checked-in"]
|
||||
BIN --> OT["Odin tests<br/>assert identical values"]
|
||||
BIN --> RT["Rust tests<br/>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 <schema>.fbs <data>.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<u64>` 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`
|
||||
@@ -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<T>`/`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.
|
||||
@@ -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<CatalogObject>
|
||||
- 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/
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Prototype schema<br/>catalog.fbs: Catalog · CatalogObject · ServerMessage union"]
|
||||
B["Generate Rust code<br/>flatc --rust → api/build.rs · serve WS via axum"]
|
||||
C["Prototype the Odin reader<br/>flatcc FFI · pure-Odin · OdinArrow"]
|
||||
D["Static fixture files<br/>flatc --binary → committed .bin"]
|
||||
E["Cross-language tests<br/>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
|
||||
`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`).
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
+11
-2
@@ -18,10 +18,19 @@ case "$ARCH" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# 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})"
|
||||
|
||||
Reference in New Issue
Block a user