use super::*;
use crate::ValueAt;
use crate::nodes::{NodeOperators, StreamOperators, constant, produce_async};
use crate::types::{Burst, Element, NanoTime, Stream};
use crate::{RunFor, RunMode, burst};
use std::rc::Rc;
use testcontainers::{GenericImage, ImageExt, core::WaitFor, runners::SyncRunner};
use tokio_postgres::NoTls;
const PG_PORT: u16 = 5432;
const PG_IMAGE: &str = "postgres";
const PG_TAG: &str = "16-alpine";
#[derive(Debug, Clone, Default, PartialEq)]
struct TestTrade {
sym: String,
price: f64,
qty: i64,
}
impl PostgresDeserialize for TestTrade {
fn from_row(row: &Row) -> anyhow::Result<(NanoTime, Self)> {
Ok((
row.get_nanotime(0)?, TestTrade {
sym: row.try_get(1)?,
price: row.try_get(2)?,
qty: row.try_get(3)?,
},
))
}
}
impl PostgresSerialize for TestTrade {
fn to_params(&self) -> Vec<Box<dyn ToSql + Sync + Send>> {
vec![
Box::new(self.sym.clone()),
Box::new(self.price),
Box::new(self.qty),
]
}
}
fn start_postgres() -> anyhow::Result<(impl Drop, PostgresConnection)> {
let container = GenericImage::new(PG_IMAGE, PG_TAG)
.with_wait_for(WaitFor::message_on_stderr(
"database system is ready to accept connections",
))
.with_env_var("POSTGRES_PASSWORD", "postgres")
.start()?;
let port = container.get_host_port_ipv4(PG_PORT)?;
let conn_str = format!(
"host=127.0.0.1 port={port} user=postgres password=postgres dbname=postgres connect_timeout=10"
);
Ok((container, PostgresConnection::new(conn_str)))
}
fn exec(conn: &PostgresConnection, stmts: &[&str]) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let (client, connection) = tokio_postgres::connect(&conn.conn_str, NoTls).await?;
tokio::spawn(async move {
let _ = connection.await;
});
for stmt in stmts {
client.batch_execute(stmt).await?;
}
Ok::<(), anyhow::Error>(())
})
}
fn scalar_i64(conn: &PostgresConnection, query: &str) -> anyhow::Result<i64> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let (client, connection) = tokio_postgres::connect(&conn.conn_str, NoTls).await?;
tokio::spawn(async move {
let _ = connection.await;
});
let row = client.query_one(query, &[]).await?;
Ok::<i64, anyhow::Error>(row.get(0))
})
}
fn seed_trades(conn: &PostgresConnection, n: usize) -> anyhow::Result<()> {
exec(
conn,
&[
"CREATE TABLE trades (time timestamp, sym text, price float8, qty int8)",
&format!(
"INSERT INTO trades \
SELECT timestamp '2000-01-01 00:00:00' + (g || ' hours')::interval, \
'SYM' || g, 100.0 + g, g \
FROM generate_series(0, {}) AS g",
n - 1
),
],
)
}
fn read_trades(conn: PostgresConnection) -> Rc<dyn Stream<Burst<TestTrade>>> {
postgres_read::<TestTrade>(
conn,
std::time::Duration::from_secs(3600),
|(t0, t1), _date, _iter| {
format!(
"SELECT time, sym, price, qty FROM trades \
WHERE time >= '{}' AND time < '{}' ORDER BY time",
postgres_timestamp(t0),
postgres_timestamp(t1),
)
},
)
}
fn collect_read<T: Element + Send>(
stream: Rc<dyn Stream<Burst<T>>>,
) -> anyhow::Result<Vec<ValueAt<T>>> {
let collected = stream.collapse().collect();
collected.clone().run(
RunMode::HistoricalFrom(NanoTime::from_kdb_timestamp(0)),
RunFor::Duration(std::time::Duration::from_secs(86400)),
)?;
Ok(collected.peek_value().to_vec())
}
#[test]
fn test_connection_refused() {
let conn = PostgresConnection::new(
"host=127.0.0.1 port=59999 user=postgres dbname=postgres connect_timeout=2",
);
let result = read_trades(conn).collapse().collect().run(
RunMode::HistoricalFrom(NanoTime::from_kdb_timestamp(0)),
RunFor::Duration(std::time::Duration::from_secs(86400)),
);
assert!(result.is_err(), "expected connection error");
}
#[test]
fn test_read_time_sliced() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
seed_trades(&conn, 5)?;
let rows = collect_read(read_trades(conn))?;
assert_eq!(rows.len(), 5, "should read all 5 rows across hourly slices");
assert_eq!(rows[0].value.sym, "SYM0");
assert_eq!(rows[4].value.sym, "SYM4");
Ok(())
}
#[test]
fn test_read_timestamptz() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
exec(
&conn,
&[
"CREATE TABLE trades (time timestamptz, sym text, price float8, qty int8)",
"INSERT INTO trades \
SELECT timestamptz '2000-01-01 00:00:00+00' + (g || ' hours')::interval, \
'SYM' || g, 100.0 + g, g \
FROM generate_series(0, 2) AS g",
],
)?;
let rows = collect_read(read_trades(conn))?;
assert_eq!(rows.len(), 3, "should read all 3 timestamptz rows");
assert_eq!(rows[0].value.sym, "SYM0");
assert_eq!(rows[2].value.sym, "SYM2");
assert_eq!(rows[0].time, NanoTime::from_kdb_timestamp(0));
Ok(())
}
#[test]
fn test_read_drops_rows_before_start() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
exec(
&conn,
&[
"CREATE TABLE trades (time timestamp, sym text, price float8, qty int8)",
"INSERT INTO trades VALUES \
('2000-01-01 00:15:00', 'EARLY', 1.0, 1), \
('2000-01-01 00:45:00', 'INWIN', 2.0, 2)",
],
)?;
let start = NanoTime::from_kdb_timestamp(30 * 60 * 1_000_000_000);
let stream = read_trades(conn);
let collected = stream.collapse().collect();
collected.clone().run(
RunMode::HistoricalFrom(start),
RunFor::Duration(std::time::Duration::from_secs(3600)),
)?;
let syms: Vec<String> = collected
.peek_value()
.iter()
.map(|v| v.value.sym.clone())
.collect();
assert_eq!(
syms,
vec!["INWIN"],
"pre-start row must be dropped, in-window row kept"
);
Ok(())
}
#[test]
fn test_read_empty_table() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
exec(
&conn,
&["CREATE TABLE trades (time timestamp, sym text, price float8, qty int8)"],
)?;
let rows = collect_read(read_trades(conn))?;
assert_eq!(rows.len(), 0, "empty table should yield 0 rows");
Ok(())
}
#[test]
fn test_write_round_trip() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
exec(
&conn,
&["CREATE TABLE trades (time timestamp, sym text, price float8, qty int8)"],
)?;
let write_conn = conn.clone();
let producer = produce_async(
move |_ctx| async move {
Ok(async_stream::stream! {
for i in 0..3i64 {
let time = NanoTime::from_kdb_timestamp(i * 3_600_000_000_000);
yield Ok((time, TestTrade { sym: format!("W{i}"), price: 10.0 + i as f64, qty: i }));
}
})
},
None,
);
postgres_write(write_conn, "trades", &producer)
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Forever)?;
assert_eq!(scalar_i64(&conn, "SELECT count(*) FROM trades")?, 3);
let rows = collect_read(read_trades(conn))?;
assert_eq!(rows.len(), 3, "should read back 3 written rows");
assert_eq!(rows[0].value.sym, "W0");
assert!((rows[0].value.price - 10.0).abs() < 1e-9);
assert_eq!(rows[2].value.qty, 2);
Ok(())
}
#[test]
fn test_sub_catch_up_then_live_inserts() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
exec(
&conn,
&[
"CREATE TABLE trades (time timestamp, sym text, price float8, qty int8)",
&postgres_notify_trigger_sql("trades", "trades_feed"),
"INSERT INTO trades VALUES
('2000-01-01 00:00:01', 'SEED1', 1.0, 1),
('2000-01-01 00:00:02', 'SEED2', 2.0, 2)",
],
)?;
let insert_conn = conn.clone();
let inserter = std::thread::spawn(move || -> anyhow::Result<()> {
std::thread::sleep(std::time::Duration::from_millis(500));
exec(
&insert_conn,
&[
"INSERT INTO trades VALUES ('2000-01-01 00:00:03', 'LIVE3', 3.0, 3)",
"INSERT INTO trades VALUES ('2000-01-01 00:00:04', 'LIVE4', 4.0, 4)",
],
)
});
let stream = postgres_sub::<TestTrade, _>(
conn,
"trades_feed",
NanoTime::from_kdb_timestamp(0), |cursor| {
format!(
"SELECT time, sym, price, qty FROM trades \
WHERE time > '{}' ORDER BY time",
postgres_timestamp(cursor),
)
},
);
let collected = stream.collect();
collected.clone().run(
RunMode::RealTime,
RunFor::Duration(std::time::Duration::from_secs(3)),
)?;
inserter.join().expect("inserter thread panicked")?;
let syms: Vec<String> = collected
.peek_value()
.iter()
.flat_map(|tick| tick.value.iter().map(|t| t.sym.clone()))
.collect();
assert_eq!(
syms,
vec!["SEED1", "SEED2", "LIVE3", "LIVE4"],
"catch-up rows then live rows, in time order"
);
Ok(())
}
#[test]
fn test_write_burst_multi_row() -> anyhow::Result<()> {
let _ = env_logger::try_init();
let (_container, conn) = start_postgres()?;
exec(
&conn,
&["CREATE TABLE trades (time timestamp, sym text, price float8, qty int8)"],
)?;
constant(burst![
TestTrade {
sym: "A".into(),
price: 1.0,
qty: 1
},
TestTrade {
sym: "B".into(),
price: 2.0,
qty: 2
},
])
.postgres_write(conn.clone(), "trades")
.run(
RunMode::HistoricalFrom(NanoTime::from_kdb_timestamp(0)),
RunFor::Cycles(1),
)?;
assert_eq!(scalar_i64(&conn, "SELECT count(*) FROM trades")?, 2);
Ok(())
}