use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver, Sender};
use anyhow::{anyhow, Result};
use ggsql::reader::Spec;
use super::{Format, RenderRequest, RenderTicket};
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const PRE_RENDER_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
pub struct RenderOutcome {
pub ticket: Box<RenderTicket>,
pub result: Result<Vec<u8>>,
}
enum Job {
Store { comm_id: String, spec: Box<Spec> },
Forget { comm_id: String },
RenderStored {
comm_id: String,
request: RenderRequest,
reply: Sender<Result<Vec<u8>>>,
},
Render {
comm_id: String,
request: RenderRequest,
ticket: Box<RenderTicket>,
},
RenderOnce {
spec: Box<Spec>,
request: RenderRequest,
reply: Sender<Result<Vec<u8>>>,
},
Shutdown,
}
pub struct PlotBackend {
jobs: Sender<Job>,
raster: bool,
probe: Option<Receiver<bool>>,
}
impl PlotBackend {
pub fn spawn(outcomes: tokio::sync::mpsc::UnboundedSender<RenderOutcome>) -> Self {
Self::start(true, Some(outcomes))
}
pub fn finish_probe(&mut self) {
let Some(probe) = self.probe.take() else {
return;
};
self.raster = probe.recv_timeout(PROBE_TIMEOUT).unwrap_or_else(|_| {
tracing::warn!("GPU probe did not finish within {PROBE_TIMEOUT:?}");
false
});
if self.raster {
tracing::info!("GPU adapter available; raster plot formats enabled");
} else if cfg!(feature = "raster-plots") {
tracing::info!("no GPU adapter; plots will render as SVG");
} else {
tracing::info!("built without the raster-plots feature; plots will render as SVG");
}
}
#[cfg(test)]
pub fn without_raster() -> Self {
Self::start(false, None)
}
fn start(
allow_raster: bool,
outcomes: Option<tokio::sync::mpsc::UnboundedSender<RenderOutcome>>,
) -> Self {
let (jobs, inbox) = mpsc::channel();
let (probed, probe_result) = mpsc::channel();
std::thread::Builder::new()
.name("ggsql-render".to_string())
.spawn(move || render_loop(inbox, probed, allow_raster, outcomes))
.expect("failed to spawn the render thread");
Self {
jobs,
raster: false,
probe: allow_raster.then_some(probe_result),
}
}
pub fn raster(&self) -> bool {
self.raster
}
pub fn render_once(&self, spec: Box<Spec>, request: RenderRequest) -> Result<Vec<u8>> {
let (reply, answer) = mpsc::channel();
self.jobs
.send(Job::RenderOnce {
spec,
request,
reply,
})
.map_err(|_| anyhow!("the render thread has stopped"))?;
answer
.recv()
.map_err(|_| anyhow!("the render thread stopped while rendering"))?
}
pub fn store(&self, comm_id: String, spec: Box<Spec>) {
let _ = self.jobs.send(Job::Store { comm_id, spec });
}
pub fn forget(&self, comm_id: &str) {
let _ = self.jobs.send(Job::Forget {
comm_id: comm_id.to_string(),
});
}
pub fn render_stored(&self, comm_id: &str, request: RenderRequest) -> Result<Vec<u8>> {
let (reply, answer) = mpsc::channel();
self.jobs
.send(Job::RenderStored {
comm_id: comm_id.to_string(),
request,
reply,
})
.map_err(|_| anyhow!("the render thread has stopped"))?;
answer
.recv_timeout(PRE_RENDER_BUDGET)
.map_err(|_| anyhow!("the render did not finish within {PRE_RENDER_BUDGET:?}"))?
}
pub fn request_render(
&self,
comm_id: &str,
request: RenderRequest,
ticket: RenderTicket,
) -> Result<()> {
self.jobs
.send(Job::Render {
comm_id: comm_id.to_string(),
request,
ticket: Box::new(ticket),
})
.map_err(|_| anyhow!("the render thread has stopped"))
}
}
impl Drop for PlotBackend {
fn drop(&mut self) {
let _ = self.jobs.send(Job::Shutdown);
}
}
fn render_loop(
inbox: Receiver<Job>,
probed: Sender<bool>,
allow_raster: bool,
outcomes: Option<tokio::sync::mpsc::UnboundedSender<RenderOutcome>>,
) {
let mut renderer = if allow_raster {
raster_renderer()
} else {
None
};
let _ = probed.send(renderer.is_some());
warm_up(renderer.as_mut());
let mut stored: HashMap<String, Box<Spec>> = HashMap::new();
while let Ok(job) = inbox.recv() {
match job {
Job::Shutdown => break,
Job::Store { comm_id, spec } => {
stored.insert(comm_id, spec);
}
Job::Forget { comm_id } => {
stored.remove(&comm_id);
}
Job::RenderOnce {
spec,
request,
reply,
} => {
let result = render_one(&spec, &request, renderer.as_mut(), &one_shot_namespace());
let _ = reply.send(result);
}
Job::RenderStored {
comm_id,
request,
reply,
} => {
let result = match stored.get(&comm_id) {
Some(spec) => {
render_one(spec, &request, renderer.as_mut(), &comm_namespace(&comm_id))
}
None => Err(anyhow!("this plot is no longer available")),
};
let _ = reply.send(result);
}
Job::Render {
comm_id,
request,
ticket,
} => {
let result = match stored.get(&comm_id) {
Some(spec) => {
render_one(spec, &request, renderer.as_mut(), &comm_namespace(&comm_id))
}
None => Err(anyhow!("this plot is no longer available")),
};
if let Some(outcomes) = &outcomes {
let _ = outcomes.send(RenderOutcome { ticket, result });
}
}
}
}
}
fn warm_up(renderer: Option<&mut Renderer>) {
const QUERY: &str = "SELECT 1 AS x, 1 AS y VISUALISE x AS x, y AS y DRAW point";
let started = std::time::Instant::now();
let spec = match ggsql::reader::connection::reader_from_uri("duckdb://memory")
.and_then(|reader| reader.execute(QUERY))
{
Ok(spec) => spec,
Err(e) => {
tracing::debug!("renderer warm-up skipped: {e}");
return;
}
};
let request = RenderRequest {
format: Format::Svg,
canvas: super::Canvas {
width: 64,
height: 64,
dpi: 96.0,
},
};
match render_one(&spec, &request, renderer, "warmup-") {
Ok(_) => tracing::debug!("renderer warmed up in {:?}", started.elapsed()),
Err(e) => tracing::debug!("renderer warm-up failed: {e}"),
}
}
#[cfg(feature = "raster-plots")]
fn raster_renderer() -> Option<ggsql::writer::RasterRenderer> {
match ggsql::writer::RasterRenderer::new() {
Ok(renderer) => Some(renderer),
Err(e) => {
tracing::info!("no GPU renderer: {e}");
None
}
}
}
#[cfg(not(feature = "raster-plots"))]
fn raster_renderer() -> Option<Never> {
None
}
#[cfg(not(feature = "raster-plots"))]
pub enum Never {}
#[cfg(feature = "raster-plots")]
type Renderer = ggsql::writer::RasterRenderer;
#[cfg(not(feature = "raster-plots"))]
type Renderer = Never;
fn one_shot_namespace() -> String {
format!("p{}-", uuid::Uuid::new_v4())
}
fn comm_namespace(comm_id: &str) -> String {
format!("p{comm_id}-")
}
fn render_one(
spec: &Spec,
request: &RenderRequest,
renderer: Option<&mut Renderer>,
id_namespace: &str,
) -> Result<Vec<u8>> {
let canvas = request.canvas;
match request.format {
Format::Svg => {
let writer = ggsql::writer::SvgWriter::new(canvas.width, canvas.height, canvas.dpi)
.id_prefix(id_namespace);
let (svg, warnings) = writer.render_reporting(spec)?;
report(&warnings, "svg");
Ok(svg.into_bytes())
}
Format::Pdf => {
let writer = ggsql::writer::PdfWriter::new(canvas.width, canvas.height, canvas.dpi);
let (pdf, warnings) = writer.render_reporting(spec)?;
report(&warnings, "pdf");
Ok(pdf)
}
#[cfg(feature = "raster-plots")]
Format::Png | Format::Jpeg | Format::Tiff => {
let renderer = renderer.ok_or_else(|| {
anyhow!("this plot needs a GPU adapter, and none was found at startup")
})?;
match request.format {
Format::Png => {
Ok(
ggsql::writer::PngWriter::new(canvas.width, canvas.height, canvas.dpi)
.compression(ggsql::writer::PngCompression::Fast)
.render_with(spec, renderer)?,
)
}
Format::Jpeg => {
Ok(
ggsql::writer::JpegWriter::new(canvas.width, canvas.height, canvas.dpi)
.render_with(spec, renderer)?,
)
}
Format::Tiff => {
Ok(
ggsql::writer::TiffWriter::new(canvas.width, canvas.height, canvas.dpi)
.render_with(spec, renderer)?,
)
}
Format::Svg | Format::Pdf => unreachable!("handled above"),
}
}
#[cfg(not(feature = "raster-plots"))]
Format::Png | Format::Jpeg | Format::Tiff => {
let _ = renderer;
Err(anyhow!(
"this build has no raster plot formats; rebuild with --features raster-plots"
))
}
}
}
fn report(warnings: &[String], format: &str) {
for warning in warnings {
tracing::warn!("{format}: {warning}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn a_spec() -> Box<Spec> {
use ggsql::reader::{DuckDBReader, Reader};
let query = "SELECT * FROM (VALUES (1,2,10),(2,3,50),(3,1,90)) t(x,y,c) \
VISUALISE x AS x, y AS y, c AS color DRAW point";
Box::new(
DuckDBReader::from_connection_string("duckdb://memory")
.unwrap()
.execute(query)
.unwrap(),
)
}
fn an_svg_request() -> RenderRequest {
RenderRequest {
format: Format::Svg,
canvas: super::super::Canvas {
width: 400,
height: 300,
dpi: 96.0,
},
}
}
fn ids(svg: &[u8]) -> Vec<String> {
let svg = std::str::from_utf8(svg).unwrap();
svg.match_indices("id=\"")
.map(|(at, marker)| {
let rest = &svg[at + marker.len()..];
rest[..rest.find('"').unwrap()].to_string()
})
.collect()
}
#[test]
fn a_static_plot_gets_its_own_id_namespace() {
let backend = PlotBackend::without_raster();
let first = backend
.render_once(a_spec(), an_svg_request())
.expect("the SVG path needs no adapter");
let second = backend
.render_once(a_spec(), an_svg_request())
.expect("the SVG path needs no adapter");
let (first, second) = (ids(&first), ids(&second));
assert!(!first.is_empty(), "the plot defines no ids to namespace");
for id in &first {
assert!(
!second.contains(id),
"'{id}' would collide across two cells"
);
}
}
#[test]
fn a_stored_plot_keeps_one_namespace_across_renders() {
let backend = PlotBackend::without_raster();
backend.store("comm-1".to_string(), a_spec());
let first = backend.render_stored("comm-1", an_svg_request()).unwrap();
let second = backend.render_stored("comm-1", an_svg_request()).unwrap();
assert_eq!(ids(&first), ids(&second));
assert!(ids(&first).iter().all(|id| id.starts_with("pcomm-1-")));
}
#[test]
fn a_namespace_is_a_valid_xml_name() {
for namespace in [one_shot_namespace(), comm_namespace("8-4-4-4-12")] {
let first = namespace.chars().next().unwrap();
assert!(
first.is_ascii_alphabetic() || first == '_',
"'{namespace}' is not a valid XML name"
);
}
}
}