add structured logging
This commit is contained in:
Generated
+83
@@ -561,6 +561,21 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-channel"
|
||||||
|
version = "0.5.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@@ -598,6 +613,12 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_arbitrary"
|
name = "derive_arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -867,6 +888,7 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1474,6 +1496,12 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.47"
|
version = "0.1.47"
|
||||||
@@ -1598,6 +1626,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -2088,6 +2122,12 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symlink"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -2204,6 +2244,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-keccak"
|
name = "tiny-keccak"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -2366,6 +2436,19 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-appender"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-channel",
|
||||||
|
"symlink",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ serde_json = "1"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
duckdb = { version = "1.10505", features = ["bundled"] }
|
duckdb = { version = "1.10505", features = ["bundled"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
|||||||
+31
-28
@@ -1,6 +1,6 @@
|
|||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
Config, LandingDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
|
Config, LandingDto, LookupDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
@@ -394,11 +394,9 @@ async fn get_summary(
|
|||||||
async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
||||||
let conn = state.conn.clone();
|
let conn = state.conn.clone();
|
||||||
|
|
||||||
let (file_path, file) =
|
let file_path = tokio::task::spawn_blocking(move || -> ApiResult<String> {
|
||||||
tokio::task::spawn_blocking(move || -> ApiResult<(String, std::fs::File)> {
|
|
||||||
let conn = conn.blocking_lock();
|
let conn = conn.blocking_lock();
|
||||||
|
|
||||||
// Use builder to ensure consistent naming for cleanup
|
|
||||||
let tmp = tempfile::Builder::new()
|
let tmp = tempfile::Builder::new()
|
||||||
.prefix("hagfish-")
|
.prefix("hagfish-")
|
||||||
.suffix(".parquet")
|
.suffix(".parquet")
|
||||||
@@ -423,12 +421,9 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
|||||||
|
|
||||||
db::export_parquet(&conn, &path_str)?;
|
db::export_parquet(&conn, &path_str)?;
|
||||||
|
|
||||||
let file = std::fs::File::open(&path_str).map_err(|e| ApiError {
|
drop(conn);
|
||||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
message: format!("Failed to open exported file: {e}"),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok((path_str, file))
|
Ok(path_str)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError {
|
.map_err(|e| ApiError {
|
||||||
@@ -436,8 +431,14 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
|||||||
message: format!("Task join error: {e}"),
|
message: format!("Task join error: {e}"),
|
||||||
})??;
|
})??;
|
||||||
|
|
||||||
let tokio_file = tokio::fs::File::from(file);
|
let file = tokio::fs::File::open(&file_path)
|
||||||
let stream = tokio_util::io::ReaderStream::new(tokio_file);
|
.await
|
||||||
|
.map_err(|e| ApiError {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: format!("Failed to open exported file: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stream = tokio_util::io::ReaderStream::new(file);
|
||||||
let body = Body::from_stream(stream);
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
let cleanup_path = file_path.clone();
|
let cleanup_path = file_path.clone();
|
||||||
@@ -448,7 +449,7 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
.header(
|
.header(
|
||||||
@@ -456,7 +457,10 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
|||||||
"attachment; filename=\"landings.parquet\"",
|
"attachment; filename=\"landings.parquet\"",
|
||||||
)
|
)
|
||||||
.body(body)
|
.body(body)
|
||||||
.unwrap())
|
.map_err(|e| ApiError {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: format!("Failed to build response: {e}"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn static_handler(uri: axum::http::Uri) -> Response {
|
async fn static_handler(uri: axum::http::Uri) -> Response {
|
||||||
@@ -498,13 +502,13 @@ async fn static_handler(uri: axum::http::Uri) -> Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
|
async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<LookupDto>>> {
|
||||||
let conn = state.conn.clone();
|
let conn = state.conn.clone();
|
||||||
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
|
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<LookupDto>> {
|
||||||
let conn = conn.blocking_lock();
|
let conn = conn.blocking_lock();
|
||||||
let mut stmt = conn.prepare("SELECT code, label FROM zone ORDER BY code")?;
|
let mut stmt = conn.prepare("SELECT code, label FROM zone ORDER BY code")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(SpeciesDto {
|
Ok(LookupDto {
|
||||||
code: row.get(0)?,
|
code: row.get(0)?,
|
||||||
label: row.get(1)?,
|
label: row.get(1)?,
|
||||||
})
|
})
|
||||||
@@ -520,13 +524,13 @@ async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesD
|
|||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_gear(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
|
async fn get_gear(State(state): State<AppState>) -> ApiResult<Json<Vec<LookupDto>>> {
|
||||||
let conn = state.conn.clone();
|
let conn = state.conn.clone();
|
||||||
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
|
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<LookupDto>> {
|
||||||
let conn = conn.blocking_lock();
|
let conn = conn.blocking_lock();
|
||||||
let mut stmt = conn.prepare("SELECT code, label FROM gear ORDER BY code")?;
|
let mut stmt = conn.prepare("SELECT code, label FROM gear ORDER BY code")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(SpeciesDto {
|
Ok(LookupDto {
|
||||||
code: row.get(0)?,
|
code: row.get(0)?,
|
||||||
label: row.get(1)?,
|
label: row.get(1)?,
|
||||||
})
|
})
|
||||||
@@ -962,7 +966,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_concurrent_export_requests() {
|
async fn test_concurrent_export_requests() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let app = build_router(state.clone());
|
let app = build_router(state);
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
@@ -970,12 +974,11 @@ mod tests {
|
|||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp1 = reqwest::get(format!("http://{addr}/api/export.parquet"))
|
let url = format!("http://{addr}/api/export.parquet");
|
||||||
.await
|
let (resp1, resp2) = tokio::join!(reqwest::get(&url), reqwest::get(&url));
|
||||||
.unwrap();
|
|
||||||
let resp2 = reqwest::get(format!("http://{addr}/api/export.parquet"))
|
let resp1 = resp1.unwrap();
|
||||||
.await
|
let resp2 = resp2.unwrap();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(resp1.status(), StatusCode::OK);
|
assert_eq!(resp1.status(), StatusCode::OK);
|
||||||
assert_eq!(resp2.status(), StatusCode::OK);
|
assert_eq!(resp2.status(), StatusCode::OK);
|
||||||
@@ -985,8 +988,8 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(&bytes1[..4], b"PAR1");
|
assert_eq!(&bytes1[..4], b"PAR1");
|
||||||
assert_eq!(&bytes2[..4], b"PAR1");
|
assert_eq!(&bytes2[..4], b"PAR1");
|
||||||
assert_ne!(bytes1.len(), 0);
|
assert!(!bytes1.is_empty());
|
||||||
assert_ne!(bytes2.len(), 0);
|
assert!(!bytes2.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+2
-9
@@ -1,13 +1,11 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Faroese fisheries data pipeline and dashboard
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "hagfish", version, about)]
|
#[command(name = "hagfish", version, about)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Path to configuration file
|
#[arg(short, long, value_name = "FILE")]
|
||||||
#[arg(short, long, value_name = "FILE", default_value = "config.json")]
|
pub config: Option<PathBuf>,
|
||||||
pub config: PathBuf,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
@@ -15,17 +13,12 @@ pub struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Fetch data from Hagstova API and store in DuckDB
|
|
||||||
Ingest {
|
Ingest {
|
||||||
/// Force full backfill instead of incremental
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
full: bool,
|
full: bool,
|
||||||
},
|
},
|
||||||
/// Start the HTTP server
|
|
||||||
Serve,
|
Serve,
|
||||||
/// Export landings data to Parquet
|
|
||||||
Export {
|
Export {
|
||||||
/// Output file path
|
|
||||||
#[arg(short, long, value_name = "FILE")]
|
#[arg(short, long, value_name = "FILE")]
|
||||||
out: PathBuf,
|
out: PathBuf,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const LOOKUP_TABLES: &[(&str, &str)] = &[
|
|||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
#[error("DuckDB error: {0}")]
|
#[error("DuckDB error: {0}")]
|
||||||
Duckdb(#[from] duckdb::Error),
|
Duckdb(#[from] duckdb::Error),
|
||||||
#[error("invalid export path: contains single quote: {0}")]
|
#[error("invalid export path: {0}")]
|
||||||
InvalidPath(String),
|
InvalidPath(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +156,16 @@ pub fn export_parquet(conn: &Connection, path: &str) -> Result<()> {
|
|||||||
if path.contains('\'') || path.contains('\0') {
|
if path.contains('\'') || path.contains('\0') {
|
||||||
return Err(DbError::InvalidPath(path.to_string()));
|
return Err(DbError::InvalidPath(path.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !path
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | ' '))
|
||||||
|
{
|
||||||
|
return Err(DbError::InvalidPath(format!(
|
||||||
|
"Path contains disallowed characters: {path}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
&format!(
|
&format!(
|
||||||
"COPY (SELECT * FROM landings) TO '{}' (FORMAT PARQUET)",
|
"COPY (SELECT * FROM landings) TO '{}' (FORMAT PARQUET)",
|
||||||
|
|||||||
+10
-11
@@ -12,12 +12,10 @@ const DIM_PRESERVATION: &str = "Preservation (EUMOFAPreservation)";
|
|||||||
const DIM_SHIPSIZE: &str = "Shipsize";
|
const DIM_SHIPSIZE: &str = "Shipsize";
|
||||||
const DIM_MEASURE: &str = "measure";
|
const DIM_MEASURE: &str = "measure";
|
||||||
|
|
||||||
const SENTINEL_VALUES: [f64; 1] = [-1.0];
|
const SENTINEL_VALUE: f64 = -1.0;
|
||||||
|
|
||||||
fn is_sentinel(v: f64) -> bool {
|
fn is_sentinel(v: f64) -> bool {
|
||||||
SENTINEL_VALUES
|
v.total_cmp(&SENTINEL_VALUE) == std::cmp::Ordering::Equal
|
||||||
.iter()
|
|
||||||
.any(|s| v.total_cmp(s) == std::cmp::Ordering::Equal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_metadata(client: &Client, url: &str) -> Result<(LookupMap, MetadataResponse)> {
|
pub async fn fetch_metadata(client: &Client, url: &str) -> Result<(LookupMap, MetadataResponse)> {
|
||||||
@@ -83,13 +81,14 @@ pub fn chunk_months(months: &[String], batch_size: usize) -> Vec<Vec<String>> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_query(all_months: &[String]) -> Query {
|
pub fn build_query(all_months: &[String]) -> Result<Query> {
|
||||||
assert!(
|
if all_months.is_empty() {
|
||||||
!all_months.is_empty(),
|
return Err(IngestError::InvalidValueCode(
|
||||||
"build_query requires at least one month"
|
"build_query requires at least one month".to_string(),
|
||||||
);
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Query {
|
Ok(Query {
|
||||||
query: vec![
|
query: vec![
|
||||||
QueryItem {
|
QueryItem {
|
||||||
code: DIM_MONTH.to_string(),
|
code: DIM_MONTH.to_string(),
|
||||||
@@ -149,7 +148,7 @@ pub fn build_query(all_months: &[String]) -> Query {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
response: QueryResponse::default(),
|
response: QueryResponse::default(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_data(client: &Client, url: &str, query: &Query) -> Result<DataResponse> {
|
pub async fn fetch_data(client: &Client, url: &str, query: &Query) -> Result<DataResponse> {
|
||||||
|
|||||||
+58
-18
@@ -13,16 +13,45 @@ use clap::Parser;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
fn init_logging() {
|
struct LoggerGuard {
|
||||||
|
_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging(log_file_path: Option<&str>) -> LoggerGuard {
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
if std::env::var("HAGFISH_LOG_FORMAT").as_deref() == Ok("json") {
|
let is_json = std::env::var("HAGFISH_LOG_FORMAT").as_deref() == Ok("json");
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(filter)
|
match log_file_path {
|
||||||
.json()
|
Some(path) => {
|
||||||
.init();
|
let file = std::fs::File::create(path).expect("Failed to create log file");
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file);
|
||||||
|
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter.clone())
|
||||||
|
.with_writer(non_blocking);
|
||||||
|
|
||||||
|
if is_json {
|
||||||
|
subscriber.json().init();
|
||||||
} else {
|
} else {
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
subscriber.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoggerGuard {
|
||||||
|
_guard: Some(guard),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let subscriber = tracing_subscriber::fmt().with_env_filter(filter.clone());
|
||||||
|
|
||||||
|
if is_json {
|
||||||
|
subscriber.json().init();
|
||||||
|
} else {
|
||||||
|
subscriber.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoggerGuard { _guard: None }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +80,23 @@ fn cleanup_stale_parquet_files() {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
init_logging();
|
|
||||||
|
|
||||||
let cli = cli::Cli::parse();
|
let cli = cli::Cli::parse();
|
||||||
let config = types::Config::load(&cli.config)?;
|
|
||||||
|
let config = match &cli.config {
|
||||||
|
Some(path) => types::Config::load(path)?,
|
||||||
|
None => {
|
||||||
|
let default_path = std::path::Path::new("config.json");
|
||||||
|
if default_path.exists() {
|
||||||
|
types::Config::load(default_path)?
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No config file found, using defaults");
|
||||||
|
types::Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize logging AFTER config is loaded so we have log_file_path
|
||||||
|
let _logger_guard = init_logging(config.log_file_path.as_deref());
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
cli::Command::Serve => run_serve(&config).await,
|
cli::Command::Serve => run_serve(&config).await,
|
||||||
@@ -142,7 +184,6 @@ async fn run_ingest(config: &types::Config, full: bool) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut total_rows = 0;
|
let mut total_rows = 0;
|
||||||
let mut total_skipped = 0;
|
|
||||||
let mut errors = 0;
|
let mut errors = 0;
|
||||||
|
|
||||||
for (i, batch) in batches.iter().enumerate() {
|
for (i, batch) in batches.iter().enumerate() {
|
||||||
@@ -156,7 +197,7 @@ async fn run_ingest(config: &types::Config, full: bool) -> anyhow::Result<()> {
|
|||||||
"fetching batch"
|
"fetching batch"
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = ingest::build_query(batch);
|
let query = ingest::build_query(batch)?;
|
||||||
|
|
||||||
let data = match ingest::fetch_data(&client, &config.data_source_url, &query).await {
|
let data = match ingest::fetch_data(&client, &config.data_source_url, &query).await {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@@ -178,11 +219,7 @@ async fn run_ingest(config: &types::Config, full: bool) -> anyhow::Result<()> {
|
|||||||
for j in 0..data.dataset.value.len() {
|
for j in 0..data.dataset.value.len() {
|
||||||
match ingest::parse_row(j, &data.dataset, &lookup_maps) {
|
match ingest::parse_row(j, &data.dataset, &lookup_maps) {
|
||||||
Ok(row) => {
|
Ok(row) => {
|
||||||
if row.value.is_some() {
|
|
||||||
landings.push(ingest::data_row_to_landing(&row));
|
landings.push(ingest::data_row_to_landing(&row));
|
||||||
} else {
|
|
||||||
total_skipped += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -218,11 +255,15 @@ async fn run_ingest(config: &types::Config, full: bool) -> anyhow::Result<()> {
|
|||||||
batch = i + 1,
|
batch = i + 1,
|
||||||
total_batches = batches.len(),
|
total_batches = batches.len(),
|
||||||
rows_inserted = count,
|
rows_inserted = count,
|
||||||
null_rows_skipped = total_skipped,
|
|
||||||
parse_errors = parse_errors,
|
parse_errors = parse_errors,
|
||||||
elapsed_ms = batch_elapsed.as_millis() as u64,
|
elapsed_ms = batch_elapsed.as_millis() as u64,
|
||||||
"batch complete"
|
"batch complete"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Backoff between batches to avoid rate limiting
|
||||||
|
if i < batches.len() - 1 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsed = run_start.elapsed();
|
let elapsed = run_start.elapsed();
|
||||||
@@ -232,7 +273,6 @@ async fn run_ingest(config: &types::Config, full: bool) -> anyhow::Result<()> {
|
|||||||
months_requested = months_to_fetch.len(),
|
months_requested = months_to_fetch.len(),
|
||||||
batches = batches.len(),
|
batches = batches.len(),
|
||||||
total_rows = total_rows,
|
total_rows = total_rows,
|
||||||
null_rows_skipped = total_skipped,
|
|
||||||
errors = errors,
|
errors = errors,
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
"ingestion run complete"
|
"ingestion run complete"
|
||||||
|
|||||||
+10
-7
@@ -31,15 +31,12 @@ impl Default for Config {
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||||
if path.exists() {
|
let contents = std::fs::read_to_string(path)
|
||||||
let contents = std::fs::read_to_string(path)?;
|
.map_err(|e| anyhow::anyhow!("Failed to read config file {}: {e}", path.display()))?;
|
||||||
let config: Config = serde_json::from_str(&contents)?;
|
let config: Config = serde_json::from_str(&contents)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {e}", path.display()))?;
|
||||||
tracing::info!("Loaded config from {}", path.display());
|
tracing::info!("Loaded config from {}", path.display());
|
||||||
Ok(config)
|
Ok(config)
|
||||||
} else {
|
|
||||||
tracing::warn!("Config file {} not found, using defaults", path.display());
|
|
||||||
Ok(Self::default())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +176,12 @@ pub struct SpeciesDto {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupDto {
|
||||||
|
pub code: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LandingDto {
|
pub struct LandingDto {
|
||||||
pub month: String,
|
pub month: String,
|
||||||
|
|||||||
Reference in New Issue
Block a user