uni_plugin_extism/
adapter_algorithm.rs1use std::sync::Arc;
20
21use arrow_array::{ArrayRef, Float64Array, Int64Array, RecordBatch};
22use arrow_schema::{DataType, Schema, SchemaRef};
23use datafusion::error::DataFusionError;
24use datafusion::execution::SendableRecordBatchStream;
25use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
26use uni_plugin::QName;
27use uni_plugin::errors::FnError;
28use uni_plugin::traits::algorithm::{
29 AlgorithmContext, AlgorithmProvider, AlgorithmSignature, GraphProjectionSpec,
30};
31use uni_plugin_builtin::algorithms::bridge::AlgorithmHostBridge;
32use uni_plugin_builtin::algorithms::graph_compute::handle::Handle;
33use uni_plugin_builtin::algorithms::graph_compute::{
34 AlgoSession, Arena, DEFAULT_ARENA_MAX_HANDLES, SharedRegistry, WorkBudget, next_session_epoch,
35};
36
37use crate::adapter_common::{acquire, sanitize_qname};
38use crate::pool::ExtismInstancePool;
39
40#[must_use]
42pub(crate) fn algo_invoke_export_name(qname: &QName) -> String {
43 format!("algo_{}_invoke", sanitize_qname(qname))
44}
45
46pub struct ExtismAlgorithm {
48 pool: Arc<ExtismInstancePool<extism::Plugin>>,
49 registry: SharedRegistry,
50 qname: QName,
51 invoke_export: String,
52 signature: AlgorithmSignature,
53}
54
55impl std::fmt::Debug for ExtismAlgorithm {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("ExtismAlgorithm")
58 .field("qname", &self.qname)
59 .finish_non_exhaustive()
60 }
61}
62
63impl ExtismAlgorithm {
64 #[must_use]
66 pub fn new(
67 pool: Arc<ExtismInstancePool<extism::Plugin>>,
68 registry: SharedRegistry,
69 qname: QName,
70 signature: AlgorithmSignature,
71 ) -> Self {
72 let invoke_export = algo_invoke_export_name(&qname);
73 Self {
74 pool,
75 registry,
76 qname,
77 invoke_export,
78 signature,
79 }
80 }
81}
82
83fn to_i64(h: Handle) -> i64 {
84 #[expect(
85 clippy::cast_possible_wrap,
86 reason = "opaque handle round-trips bit-exact"
87 )]
88 let v = h.as_u64() as i64;
89 v
90}
91
92impl AlgorithmProvider for ExtismAlgorithm {
93 fn signature(&self) -> &AlgorithmSignature {
94 &self.signature
95 }
96
97 fn run(&self, ctx: AlgorithmContext<'_>) -> Result<SendableRecordBatchStream, FnError> {
98 let host = ctx
99 .host
100 .ok_or_else(|| FnError::new(0x800, "extism algorithm: host unbound"))?;
101 let bridge = host
102 .as_any()
103 .downcast_ref::<AlgorithmHostBridge>()
104 .ok_or_else(|| FnError::new(0x801, "extism algorithm: host is not the bridge"))?;
105
106 let json_args: Vec<serde_json::Value> = serde_json::from_str(ctx.config_json)
107 .map_err(|e| FnError::new(0x802, format!("extism algorithm: bad config json: {e}")))?;
108
109 let spec = GraphProjectionSpec {
110 include_reverse: true, ..GraphProjectionSpec::default()
112 };
113 let projection = bridge.project_for_graph_compute(&spec);
114 let (work_cap, arena_bytes) = bridge.graph_compute_caps();
115
116 let out_schema: SchemaRef = Arc::new(Schema::new(self.signature.output_fields.clone()));
117 let schema_for_batch = Arc::clone(&out_schema);
118 let registry = Arc::clone(&self.registry);
119 let pool = Arc::clone(&self.pool);
120 let invoke_export = self.invoke_export.clone();
121 let qname_str = self.qname.to_string();
122 let expected_cols = uni_plugin_builtin::algorithms::graph_compute::guest_emit_columns(
123 &self.signature.output_fields,
124 );
125
126 let stream = futures::stream::once(async move {
127 let graph = projection
128 .await
129 .map_err(|e| DataFusionError::Execution(format!("extism algorithm: {e}")))?;
130
131 let size_budget =
132 WorkBudget::from_graph_size(graph.vertex_count() as u64, graph.edge_count() as u64)
133 .total();
134 let total = work_cap.map_or(size_budget, |w| w.min(size_budget));
135 let mut session = AlgoSession::new(
136 next_session_epoch(),
137 WorkBudget::new(total.max(1)),
138 Arena::new(arena_bytes, DEFAULT_ARENA_MAX_HANDLES),
139 )
140 .with_expected_columns(expected_cols);
141 let g = to_i64(session.bind_graph(Arc::clone(&graph)));
142 let sid = registry.open(session);
143
144 let started = std::time::Instant::now();
148 let call_result: Result<(), DataFusionError> = (|| {
149 let input = serde_json::to_vec(&serde_json::json!({
150 "session": sid, "graph": g, "args": json_args,
151 }))
152 .map_err(|e| DataFusionError::Execution(format!("extism algorithm input: {e}")))?;
153 let mut leased = acquire(&pool)
154 .map_err(|e| DataFusionError::Execution(format!("extism acquire: {e}")))?;
155 leased
156 .get_mut()
157 .call::<&[u8], &[u8]>(&invoke_export, &input)
158 .map(|_| ())
159 .map_err(|e| {
160 DataFusionError::Execution(format!("extism call `{invoke_export}`: {e}"))
161 })
162 })();
163
164 let closed = registry.close(sid);
166 if let Err(orig) = call_result {
167 let (spent, budget) = closed
171 .as_ref()
172 .map_or((0, 0), |s| (s.work_spent(), s.work_budget()));
173 return Err(
174 uni_plugin_builtin::algorithms::graph_compute::error::incomplete_tag_after_guest(
175 &qname_str,
176 false,
177 spent,
178 budget,
179 started.elapsed().as_millis() as u64,
180 )
181 .map_or(orig, DataFusionError::Execution),
182 );
183 }
184 let mut closed =
185 closed.ok_or_else(|| DataFusionError::Execution("session vanished".into()))?;
186 let emitted = closed.take_emitted();
187
188 build_batch(&schema_for_batch, &graph, &emitted)
189 .map_err(|e| DataFusionError::Execution(format!("extism algorithm emit: {e}")))
190 });
191
192 Ok(Box::pin(RecordBatchStreamAdapter::new(out_schema, stream)))
193 }
194}
195
196fn build_batch(
198 schema: &SchemaRef,
199 graph: &uni_algo::algo::GraphProjection,
200 emitted: &[(String, Vec<f64>)],
201) -> Result<RecordBatch, FnError> {
202 let n = graph.vertex_count();
203 let mut columns: Vec<ArrayRef> = Vec::with_capacity(schema.fields().len());
204 for field in schema.fields() {
205 if field.name() == "nodeId" {
206 #[expect(
207 clippy::cast_possible_wrap,
208 reason = "vids fit i64 in practice; Cypher integers are i64"
209 )]
210 let ids: Vec<i64> = (0..n as u32)
211 .map(|slot| graph.to_vid(slot).as_u64() as i64)
212 .collect();
213 columns.push(Arc::new(Int64Array::from(ids)));
214 continue;
215 }
216 let (_, values) = emitted
217 .iter()
218 .find(|(name, _)| name == field.name())
219 .ok_or_else(|| {
220 FnError::new(
221 0x869,
222 format!("guest did not emit declared column `{}`", field.name()),
223 )
224 })?;
225 match field.data_type() {
226 DataType::Float64 => columns.push(Arc::new(Float64Array::from(values.clone()))),
227 #[expect(
228 clippy::cast_possible_truncation,
229 reason = "int columns hold whole f64 values"
230 )]
231 DataType::Int64 => {
232 let ints: Vec<i64> = values.iter().map(|&v| v as i64).collect();
233 columns.push(Arc::new(Int64Array::from(ints)));
234 }
235 other => {
236 return Err(FnError::new(
237 0x862,
238 format!("unsupported emit column type {other:?}"),
239 ));
240 }
241 }
242 }
243 RecordBatch::try_new(Arc::clone(schema), columns)
244 .map_err(|e| FnError::new(0x15, format!("extism algorithm batch: {e}")))
245}