use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use arrow_array_56::builder::{BinaryBuilder, Int64Builder, StringBuilder};
use arrow_array_56::cast::AsArray;
use arrow_array_56::types::Int64Type;
use arrow_array_56::{Array, ArrayRef, RecordBatch};
use arrow_schema_56::{DataType, Field, Schema};
use nornir_build_thing::build_info::{BuildInfoRow, BuildInfoSink, BUILD_INFO_SCHEMA};
use nornir_flight::FlightConn;
use tokio::sync::{mpsc, oneshot};
pub fn route_key(build_id: &str, step_id: &str) -> String {
if step_id.is_empty() {
build_id.to_string()
} else {
format!("{build_id}/{step_id}")
}
}
pub fn build_info_flight_schema() -> Schema {
let fields = vec![
Field::new("build_id", DataType::Utf8, false),
Field::new("step_id", DataType::Utf8, false),
Field::new("ts_micros", DataType::Int64, false),
Field::new("kind", DataType::Utf8, false),
Field::new("level", DataType::Utf8, false),
Field::new("name", DataType::Utf8, false),
Field::new("value", DataType::Utf8, false),
Field::new("content_id", DataType::Utf8, false),
Field::new("blob", DataType::Binary, true),
];
let mut meta = HashMap::new();
meta.insert("schema".to_string(), BUILD_INFO_SCHEMA.to_string());
Schema::new(fields).with_metadata(meta)
}
pub fn rows_to_batch(rows: &[BuildInfoRow]) -> Result<RecordBatch> {
let n = rows.len();
let mut build_id = StringBuilder::with_capacity(n, n * 40);
let mut step_id = StringBuilder::with_capacity(n, n * 16);
let mut ts = Int64Builder::with_capacity(n);
let mut kind = StringBuilder::with_capacity(n, n * 10);
let mut level = StringBuilder::with_capacity(n, n * 8);
let mut name = StringBuilder::with_capacity(n, n * 16);
let mut value = StringBuilder::with_capacity(n, n * 32);
let mut content_id = StringBuilder::with_capacity(n, n * 64);
let mut blob = BinaryBuilder::with_capacity(n, n * 32);
for r in rows {
build_id.append_value(&r.build_id);
step_id.append_value(&r.step_id);
ts.append_value(r.ts_micros);
kind.append_value(&r.kind);
level.append_value(&r.level);
name.append_value(&r.name);
value.append_value(&r.value);
content_id.append_value(&r.content_id);
match &r.blob {
Some(b) => blob.append_value(b),
None => blob.append_null(),
}
}
let columns: Vec<ArrayRef> = vec![
Arc::new(build_id.finish()),
Arc::new(step_id.finish()),
Arc::new(ts.finish()),
Arc::new(kind.finish()),
Arc::new(level.finish()),
Arc::new(name.finish()),
Arc::new(value.finish()),
Arc::new(content_id.finish()),
Arc::new(blob.finish()),
];
RecordBatch::try_new(Arc::new(build_info_flight_schema()), columns)
.map_err(|e| anyhow!("build_info flight batch: {e}"))
}
pub fn batch_to_rows(batch: &RecordBatch) -> Result<Vec<BuildInfoRow>> {
if batch.num_columns() != 9 {
return Err(anyhow!(
"build_info batch has {} columns, expected 9",
batch.num_columns()
));
}
let build_id = batch.column(0).as_string::<i32>();
let step_id = batch.column(1).as_string::<i32>();
let ts = batch.column(2).as_primitive::<Int64Type>();
let kind = batch.column(3).as_string::<i32>();
let level = batch.column(4).as_string::<i32>();
let name = batch.column(5).as_string::<i32>();
let value = batch.column(6).as_string::<i32>();
let content_id = batch.column(7).as_string::<i32>();
let blob = batch.column(8).as_binary::<i32>();
let mut out = Vec::with_capacity(batch.num_rows());
for i in 0..batch.num_rows() {
out.push(BuildInfoRow {
build_id: build_id.value(i).to_string(),
step_id: step_id.value(i).to_string(),
ts_micros: ts.value(i),
kind: kind.value(i).to_string(),
level: level.value(i).to_string(),
name: name.value(i).to_string(),
value: value.value(i).to_string(),
content_id: content_id.value(i).to_string(),
blob: if blob.is_null(i) {
None
} else {
Some(blob.value(i).to_vec())
},
});
}
Ok(out)
}
#[derive(Debug, Clone, Default)]
pub struct FlightStats {
pub puts_ok: usize,
pub puts_err: usize,
pub last_error: Option<String>,
}
enum Msg {
Rows(Vec<BuildInfoRow>),
Flush(oneshot::Sender<FlightStats>),
}
fn group_rows(rows: &[BuildInfoRow]) -> Vec<(String, Vec<BuildInfoRow>)> {
let mut order: Vec<String> = Vec::new();
let mut map: HashMap<String, Vec<BuildInfoRow>> = HashMap::new();
for r in rows {
let k = route_key(&r.build_id, &r.step_id);
if !map.contains_key(&k) {
order.push(k.clone());
}
map.entry(k).or_default().push(r.clone());
}
order
.into_iter()
.map(|k| {
let v = map.remove(&k).expect("key just inserted");
(k, v)
})
.collect()
}
async fn worker(mut conn: FlightConn, mut rx: mpsc::UnboundedReceiver<Msg>) {
let mut stats = FlightStats::default();
while let Some(msg) = rx.recv().await {
match msg {
Msg::Rows(rows) => {
for (key, group) in group_rows(&rows) {
match rows_to_batch(&group) {
Ok(batch) => match conn.put(&key, vec![batch]).await {
Ok(()) => {
stats.puts_ok += 1;
emit_status(
"do_put", true, &key, group.len(),
);
}
Err(e) => {
stats.puts_err += 1;
stats.last_error = Some(e.to_string());
emit_status(
"do_put", false, &key, group.len(),
);
}
},
Err(e) => {
stats.puts_err += 1;
stats.last_error = Some(e.to_string());
}
}
}
}
Msg::Flush(reply) => {
let _ = reply.send(stats.clone());
}
}
}
}
pub struct FlightBuildInfoSink {
tx: mpsc::UnboundedSender<Msg>,
}
impl FlightBuildInfoSink {
pub fn connect(endpoint: impl Into<String>, token: Option<String>) -> Result<Self> {
let endpoint = endpoint.into();
let (tx, rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<()>>();
std::thread::Builder::new()
.name("build-info-flight".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = ready_tx.send(Err(anyhow!("flight sink runtime: {e}")));
return;
}
};
rt.block_on(async move {
let conn = match FlightConn::connect(endpoint).await {
Ok(c) => match token {
Some(t) if !t.is_empty() => match c.with_bearer(&t) {
Ok(c) => c,
Err(e) => {
let _ = ready_tx.send(Err(anyhow!("flight bearer: {e}")));
return;
}
},
_ => c,
},
Err(e) => {
let _ = ready_tx.send(Err(anyhow!("flight connect: {e}")));
return;
}
};
let _ = ready_tx.send(Ok(()));
worker(conn, rx).await;
});
})
.map_err(|e| anyhow!("flight sink thread: {e}"))?;
ready_rx
.recv()
.map_err(|_| anyhow!("flight sink connect thread died"))??;
Ok(Self { tx })
}
pub async fn flush(&self) -> Result<FlightStats> {
let reply_rx = self.send_flush()?;
reply_rx
.await
.map_err(|_| anyhow!("flight sink flush dropped"))
}
pub fn flush_blocking(&self) -> Result<FlightStats> {
let reply_rx = self.send_flush()?;
futures::executor::block_on(async move {
reply_rx
.await
.map_err(|_| anyhow!("flight sink flush dropped"))
})
}
fn send_flush(&self) -> Result<oneshot::Receiver<FlightStats>> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(Msg::Flush(reply_tx))
.map_err(|_| anyhow!("flight sink worker gone"))?;
Ok(reply_rx)
}
}
impl BuildInfoSink for FlightBuildInfoSink {
fn emit(&self, rows: &[BuildInfoRow]) -> Result<()> {
let _ = self.tx.send(Msg::Rows(rows.to_vec()));
Ok(())
}
}
pub async fn subscribe(
endpoint: &str,
token: Option<&str>,
key: &str,
) -> Result<Vec<BuildInfoRow>> {
let mut conn = FlightConn::connect(endpoint.to_string())
.await
.map_err(|e| anyhow!("flight connect: {e}"))?;
if let Some(t) = token {
if !t.is_empty() {
conn = conn.with_bearer(t).map_err(|e| anyhow!("flight bearer: {e}"))?;
}
}
let batches = conn
.get(key)
.await
.map_err(|e| anyhow!("do_get {key:?}: {e}"))?;
let mut rows = Vec::new();
for b in &batches {
rows.extend(batch_to_rows(b)?);
}
emit_status("do_get", true, key, rows.len());
Ok(rows)
}
#[cfg(feature = "testmatrix")]
#[inline]
pub(crate) fn emit_status(verb: &str, ok: bool, key: &str, rows: usize) {
nornir_testmatrix::functional_status(
"nornir-build-info-flight",
verb,
ok,
&format!("key={key:?} rows={rows}"),
);
}
#[cfg(not(feature = "testmatrix"))]
#[inline]
pub(crate) fn emit_status(_verb: &str, _ok: bool, _key: &str, _rows: usize) {}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{SocketAddr, TcpListener};
use std::time::Duration;
use nornir_build_thing::build_info::{
build_feed_rows, BuildInfoKind, BuildInfoRow,
};
use nornir_flight::{FlightRoutes, FlightServer, MemFlightStore};
fn free_addr() -> SocketAddr {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
}
async fn start_server(token: &str) -> String {
let addr = free_addr();
let routes = FlightRoutes::shared(Arc::new(MemFlightStore::new()));
let tok = Arc::new(token.to_string());
tokio::spawn(async move {
let _ = FlightServer::serve_with_auth(routes, addr, tok).await;
});
for _ in 0..100 {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
format!("http://{addr}")
}
fn sample_stream(build_id: &str) -> Vec<BuildInfoRow> {
let mut rows = vec![
BuildInfoRow::new(build_id, 1, BuildInfoKind::Provenance).kv("repo", "holger"),
BuildInfoRow::new(build_id, 2, BuildInfoKind::Dep).kv("feature", "full"),
BuildInfoRow::new(build_id, 3, BuildInfoKind::Artifact)
.kv("target/release/holger", "1024")
.content_id("deadbeef"),
];
rows.extend(build_feed_rows(build_id, 4, "full", true, 1, 12, 999));
rows.push(
BuildInfoRow::new(build_id, 5, BuildInfoKind::Log)
.step("cargo-build")
.level("info")
.kv("chunk", "line")
.blob(b"raw log bytes".to_vec()),
);
rows
}
#[test]
fn flight_schema_matches_canonical_f3_columns() {
let flight = build_info_flight_schema();
let f_names: Vec<&str> = flight.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(
f_names,
vec![
"build_id", "step_id", "ts_micros", "kind", "level", "name", "value",
"content_id", "blob",
],
"§f3 column names/order must match",
);
assert!(flight.field(8).is_nullable());
assert!(flight.fields().iter().take(8).all(|f| !f.is_nullable()));
assert_eq!(
flight.metadata().get("schema").map(String::as_str),
Some(BUILD_INFO_SCHEMA),
);
#[cfg(feature = "build-info-arrow")]
{
let canonical = nornir_build_thing::build_info::build_info_schema();
let c_names: Vec<&str> =
canonical.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(f_names, c_names, "arrow-56 wire vs arrow-58 canonical drift");
}
}
#[test]
fn batch_round_trips_rows_byte_identical() {
let rows = sample_stream("b-local");
let batch = rows_to_batch(&rows).unwrap();
let back = batch_to_rows(&batch).unwrap();
assert_eq!(back, rows, "arrow-56 columnize/decode must be lossless");
assert!(back.iter().any(|r| r.blob.as_deref() == Some(b"raw log bytes")));
assert!(back.iter().any(|r| r.kind == "provenance" && r.blob.is_none()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn flight_sink_ships_and_consumer_receives_byte_identical() {
let token = "a-long-enough-secret-token-1234";
let ep = start_server(token).await;
let build_id = "b-remote";
let rows = sample_stream(build_id);
let sink = FlightBuildInfoSink::connect(&ep, Some(token.to_string())).unwrap();
let dyn_sink: &dyn BuildInfoSink = &sink;
dyn_sink.emit(&rows).unwrap();
let stats = sink.flush().await.unwrap();
assert!(
stats.puts_ok >= 2 && stats.puts_err == 0,
"expected the manifest + feed keys delivered, got {stats:?}",
);
let manifest_key = route_key(build_id, "");
let step_key = route_key(build_id, "cargo-build");
let want_manifest: Vec<BuildInfoRow> =
rows.iter().filter(|r| r.step_id.is_empty()).cloned().collect();
let want_step: Vec<BuildInfoRow> =
rows.iter().filter(|r| r.step_id == "cargo-build").cloned().collect();
let got_manifest = subscribe(&ep, Some(token), &manifest_key).await.unwrap();
let got_step = subscribe(&ep, Some(token), &step_key).await.unwrap();
assert_eq!(got_manifest, want_manifest, "manifest stream must round-trip");
assert_eq!(got_step, want_step, "per-step_id feed must round-trip");
let mut all = got_manifest;
all.extend(got_step);
assert_eq!(all.len(), rows.len(), "every emitted row is reachable by ticket");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn auth_is_enforced_on_the_build_info_wire() {
let token = "a-long-enough-secret-token-1234";
let ep = start_server(token).await;
let rows = sample_stream("b-noauth");
let sink = FlightBuildInfoSink::connect(&ep, None).unwrap();
sink.emit(&rows).unwrap();
let stats = sink.flush().await.unwrap();
assert!(
stats.puts_ok == 0 && stats.puts_err > 0,
"unauthenticated do_put MUST be rejected, got {stats:?}",
);
let err = subscribe(&ep, None, &route_key("b-noauth", ""))
.await
.expect_err("unauthenticated do_get MUST be rejected");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("bearer") || msg.contains("unauthenticated") || msg.contains("no stream"),
"expected an auth rejection, got: {msg}",
);
}
#[cfg(feature = "testmatrix")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_functional_rows_for_flight_build_info() {
let ep = start_server("").await;
let rows = sample_stream("b-matrix");
let sink = FlightBuildInfoSink::connect(&ep, None).unwrap();
sink.emit(&rows).unwrap();
let _ = sink.flush().await.unwrap();
let _ = subscribe(&ep, None, &route_key("b-matrix", "")).await.unwrap();
let drained = nornir_testmatrix::drain_functional_rows();
assert!(
drained.iter().any(|r| r.suite == "nornir-build-info-flight"
&& r.test_name == "do_put"
&& r.status == "pass"),
"missing green do_put matrix row: {drained:?}",
);
assert!(
drained.iter().any(|r| r.suite == "nornir-build-info-flight"
&& r.test_name == "do_get"
&& r.status == "pass"),
"missing green do_get matrix row: {drained:?}",
);
}
}