1#![expect(
18 clippy::disallowed_types,
19 reason = "construct iceberg::Error to implement the trait"
20)]
21
22use std::collections::HashMap;
23use std::fmt::Debug;
24use std::sync::Arc;
25
26use anyhow::Context;
27use async_trait::async_trait;
28use iceberg::io::FileIO;
29use iceberg::spec::{Schema, SortOrder, TableMetadata, UnboundPartitionSpec};
30use iceberg::table::Table;
31use iceberg::{
32 Catalog, Namespace, NamespaceIdent, TableCommit, TableCreation, TableIdent, TableRequirement,
33 TableUpdate,
34};
35use itertools::Itertools;
36use jni::objects::{GlobalRef, JObject};
37use risingwave_common::global_jvm::Jvm;
38use risingwave_jni_core::call_method;
39use risingwave_jni_core::jvm_runtime::{execute_with_jni_env, jobj_to_str};
40use serde::{Deserialize, Serialize};
41use thiserror_ext::AsReport;
42
43use crate::error::ConnectorResult;
44
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47struct LoadTableResponse {
48 pub metadata_location: Option<String>,
49 pub metadata: TableMetadata,
50 pub _config: Option<HashMap<String, String>>,
51}
52
53#[derive(Debug, Serialize)]
54#[serde(rename_all = "kebab-case")]
55struct CreateTableRequest {
56 pub name: String,
58 pub location: Option<String>,
60 pub schema: Schema,
62 pub partition_spec: Option<UnboundPartitionSpec>,
64 pub write_order: Option<SortOrder>,
66 pub properties: HashMap<String, String>,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
71struct CommitTableRequest {
72 identifier: TableIdent,
73 requirements: Vec<TableRequirement>,
74 updates: Vec<TableUpdate>,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79struct CommitTableResponse {
80 metadata_location: String,
81 metadata: TableMetadata,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86struct ListNamespacesResponse {
87 namespaces: Vec<NamespaceIdent>,
88 next_page_token: Option<String>,
89}
90
91#[derive(Debug, Serialize, Deserialize)]
92#[serde(rename_all = "kebab-case")]
93struct ListTablesResponse {
94 identifiers: Vec<TableIdent>,
95 next_page_token: Option<String>,
96}
97
98impl From<&TableCreation> for CreateTableRequest {
99 fn from(value: &TableCreation) -> Self {
100 Self {
101 name: value.name.clone(),
102 location: value.location.clone(),
103 schema: value.schema.clone(),
104 partition_spec: value.partition_spec.clone(),
105 write_order: value.sort_order.clone(),
106 properties: value.properties.clone(),
107 }
108 }
109}
110
111fn namespace_to_string(namespace: &NamespaceIdent) -> String {
112 namespace.iter().join(".")
113}
114
115#[derive(Debug)]
116struct JniCatalogInner {
117 java_catalog: GlobalRef,
118 jvm: Jvm,
119}
120
121#[derive(Debug)]
122pub struct JniCatalog {
123 inner: Arc<JniCatalogInner>,
126 file_io_props: Arc<HashMap<String, String>>,
127}
128
129async fn execute_blocking_jni<T>(
133 task: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
134) -> anyhow::Result<T>
135where
136 T: Send + 'static,
137{
138 tokio::task::spawn_blocking(task)
139 .await
140 .context("Failed to join blocking Iceberg JNI catalog task")?
141}
142
143#[async_trait]
144impl Catalog for JniCatalog {
145 async fn list_namespaces(
147 &self,
148 _parent: Option<&NamespaceIdent>,
149 ) -> iceberg::Result<Vec<NamespaceIdent>> {
150 let inner = self.inner.clone();
151 execute_blocking_jni(move || {
152 execute_with_jni_env(inner.jvm, |env| {
153 let result_json =
154 call_method!(env, inner.java_catalog.as_obj(), {String listNamespaces()})
155 .with_context(|| "Failed to list iceberg namespaces".to_owned())?;
156
157 let rust_json_str = jobj_to_str(env, result_json)?;
158
159 let resp: ListNamespacesResponse = serde_json::from_str(&rust_json_str)?;
160
161 Ok(resp.namespaces)
162 })
163 })
164 .await
165 .map_err(|e| {
166 iceberg::Error::new(
167 iceberg::ErrorKind::Unexpected,
168 "Failed to list iceberg namespaces.",
169 )
170 .with_source(e)
171 })
172 }
173
174 async fn create_namespace(
176 &self,
177 namespace: &iceberg::NamespaceIdent,
178 _properties: HashMap<String, String>,
179 ) -> iceberg::Result<iceberg::Namespace> {
180 let inner = self.inner.clone();
181 let namespace = namespace.clone();
182 execute_blocking_jni(move || {
183 execute_with_jni_env(inner.jvm, |env| {
184 let namespace_str = namespace_to_string(&namespace);
185 let namespace_jstr = env.new_string(&namespace_str).unwrap();
186
187 call_method!(env, inner.java_catalog.as_obj(), {void createNamespace(String)},
188 &namespace_jstr)
189 .with_context(|| format!("Failed to create namespace: {namespace}"))?;
190
191 Ok(Namespace::new(namespace))
192 })
193 })
194 .await
195 .map_err(|e| {
196 iceberg::Error::new(
197 iceberg::ErrorKind::Unexpected,
198 "Failed to create namespace.",
199 )
200 .with_source(e)
201 })
202 }
203
204 async fn get_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<Namespace> {
206 todo!()
207 }
208
209 async fn namespace_exists(&self, namespace: &NamespaceIdent) -> iceberg::Result<bool> {
211 let inner = self.inner.clone();
212 let namespace = namespace.clone();
213 execute_blocking_jni(move || {
214 execute_with_jni_env(inner.jvm, |env| {
215 let namespace_str = namespace_to_string(&namespace);
216 let namespace_jstr = env.new_string(&namespace_str).unwrap();
217
218 let exists =
219 call_method!(env, inner.java_catalog.as_obj(), {boolean namespaceExists(String)},
220 &namespace_jstr)
221 .with_context(|| format!("Failed to check namespace exists: {namespace}"))?;
222
223 Ok(exists)
224 })
225 })
226 .await
227 .map_err(|e| {
228 iceberg::Error::new(
229 iceberg::ErrorKind::Unexpected,
230 "Failed to check namespace exists.",
231 )
232 .with_source(e)
233 })
234 }
235
236 async fn drop_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<()> {
238 todo!()
239 }
240
241 async fn list_tables(&self, namespace: &NamespaceIdent) -> iceberg::Result<Vec<TableIdent>> {
243 let inner = self.inner.clone();
244 let namespace = namespace.clone();
245 execute_blocking_jni(move || {
246 execute_with_jni_env(inner.jvm, |env| {
247 let namespace_str = namespace_to_string(&namespace);
248 let namespace_jstr = env.new_string(&namespace_str).unwrap();
249
250 let result_json =
251 call_method!(env, inner.java_catalog.as_obj(), {String listTables(String)},
252 &namespace_jstr)
253 .with_context(|| {
254 format!("Failed to list iceberg tables in namespace: {}", namespace)
255 })?;
256
257 let rust_json_str = jobj_to_str(env, result_json)?;
258
259 let resp: ListTablesResponse = serde_json::from_str(&rust_json_str)?;
260
261 Ok(resp.identifiers)
262 })
263 })
264 .await
265 .map_err(|e| {
266 iceberg::Error::new(
267 iceberg::ErrorKind::Unexpected,
268 "Failed to list iceberg tables.",
269 )
270 .with_source(e)
271 })
272 }
273
274 async fn update_namespace(
275 &self,
276 _namespace: &NamespaceIdent,
277 _properties: HashMap<String, String>,
278 ) -> iceberg::Result<()> {
279 todo!()
280 }
281
282 async fn create_table(
284 &self,
285 namespace: &NamespaceIdent,
286 creation: TableCreation,
287 ) -> iceberg::Result<Table> {
288 let inner = self.inner.clone();
289 let file_io_props = self.file_io_props.clone();
290 let namespace = namespace.clone();
291 execute_blocking_jni(move || {
292 execute_with_jni_env(inner.jvm, |env| {
293 let namespace_str = namespace_to_string(&namespace);
294 let namespace_jstr = env.new_string(&namespace_str).unwrap();
295
296 let creation_str = serde_json::to_string(&CreateTableRequest::from(&creation))?;
297
298 let creation_jstr = env.new_string(&creation_str).unwrap();
299
300 let result_json =
301 call_method!(env, inner.java_catalog.as_obj(), {String createTable(String, String)},
302 &namespace_jstr, &creation_jstr)
303 .with_context(|| {
304 format!("Failed to create iceberg table: {}", creation.name)
305 })?;
306
307 let rust_json_str = jobj_to_str(env, result_json)?;
308
309 let resp: LoadTableResponse = serde_json::from_str(&rust_json_str)?;
310
311 let metadata_location = resp.metadata_location.ok_or_else(|| {
312 iceberg::Error::new(
313 iceberg::ErrorKind::FeatureUnsupported,
314 "Loading uncommitted table is not supported!",
315 )
316 })?;
317
318 let table_metadata = resp.metadata;
319
320 let file_io = FileIO::from_path(&metadata_location)?
321 .with_props(file_io_props.iter())
322 .build()?;
323
324 Ok(Table::builder()
325 .file_io(file_io)
326 .identifier(TableIdent::new(namespace, creation.name))
327 .metadata(table_metadata)
328 .build())
329 })
330 })
331 .await
332 .map_err(|e| {
333 iceberg::Error::new(
334 iceberg::ErrorKind::Unexpected,
335 "Failed to create iceberg table.",
336 )
337 .with_source(e)
338 })?
339 }
340
341 async fn load_table(&self, table: &TableIdent) -> iceberg::Result<Table> {
343 let inner = self.inner.clone();
344 let file_io_props = self.file_io_props.clone();
345 let table = table.clone();
346 execute_blocking_jni(move || {
347 execute_with_jni_env(inner.jvm, |env| {
348 let table_name_str = table.to_string();
349
350 let table_name_jstr = env.new_string(&table_name_str).unwrap();
351
352 let result_json =
353 call_method!(env, inner.java_catalog.as_obj(), {String loadTable(String)},
354 &table_name_jstr)
355 .with_context(|| format!("Failed to load iceberg table: {table_name_str}"))?;
356
357 let rust_json_str = jobj_to_str(env, result_json)?;
358
359 let resp: LoadTableResponse = serde_json::from_str(&rust_json_str)?;
360
361 let metadata_location = resp.metadata_location.ok_or_else(|| {
362 iceberg::Error::new(
363 iceberg::ErrorKind::FeatureUnsupported,
364 "Loading uncommitted table is not supported!",
365 )
366 })?;
367
368 tracing::info!(
369 "Table metadata location of {table_name_str} is {metadata_location}"
370 );
371
372 let table_metadata = resp.metadata;
373
374 let file_io = FileIO::from_path(&metadata_location)?
375 .with_props(file_io_props.iter())
376 .build()?;
377
378 Ok(Table::builder()
379 .file_io(file_io)
380 .identifier(table)
381 .metadata(table_metadata)
382 .build())
383 })
384 })
385 .await
386 .map_err(|e| {
387 iceberg::Error::new(
388 iceberg::ErrorKind::Unexpected,
389 "Failed to load iceberg table.",
390 )
391 .with_source(e)
392 })?
393 }
394
395 async fn drop_table(&self, table: &TableIdent) -> iceberg::Result<()> {
397 let inner = self.inner.clone();
398 let table = table.to_owned();
399 execute_blocking_jni(move || {
400 execute_with_jni_env(inner.jvm, |env| {
401 let table_name_str = table.to_string();
402
403 let table_name_jstr = env.new_string(&table_name_str).unwrap();
404
405 call_method!(env, inner.java_catalog.as_obj(), {boolean dropTable(String)},
406 &table_name_jstr)
407 .with_context(|| format!("Failed to drop iceberg table: {table_name_str}"))?;
408
409 Ok(())
410 })
411 })
412 .await
413 .map_err(|e| {
414 iceberg::Error::new(
415 iceberg::ErrorKind::Unexpected,
416 "Failed to drop iceberg table.",
417 )
418 .with_source(e)
419 })
420 }
421
422 async fn register_table(
423 &self,
424 _table_ident: &TableIdent,
425 _metadata_location: String,
426 ) -> iceberg::Result<Table> {
427 Err(iceberg::Error::new(
428 iceberg::ErrorKind::Unexpected,
429 "register_table is not supported by JniCatalog",
430 ))
431 }
432
433 async fn table_exists(&self, table: &TableIdent) -> iceberg::Result<bool> {
435 let inner = self.inner.clone();
436 let table = table.clone();
437 execute_blocking_jni(move || {
438 execute_with_jni_env(inner.jvm, |env| {
439 let table_name_str = table.to_string();
440
441 let table_name_jstr = env.new_string(&table_name_str).unwrap();
442
443 let exists =
444 call_method!(env, inner.java_catalog.as_obj(), {boolean tableExists(String)},
445 &table_name_jstr)
446 .with_context(|| {
447 format!("Failed to check iceberg table exists: {table_name_str}")
448 })?;
449
450 Ok(exists)
451 })
452 })
453 .await
454 .map_err(|e| {
455 iceberg::Error::new(
456 iceberg::ErrorKind::Unexpected,
457 "Failed to check iceberg table exists.",
458 )
459 .with_source(e)
460 })
461 }
462
463 async fn rename_table(&self, _src: &TableIdent, _dest: &TableIdent) -> iceberg::Result<()> {
465 todo!()
466 }
467
468 async fn update_table(&self, mut commit: TableCommit) -> iceberg::Result<Table> {
470 let inner = self.inner.clone();
471 let file_io_props = self.file_io_props.clone();
472 execute_blocking_jni(move || {
473 execute_with_jni_env(inner.jvm, |env| {
474 let requirements = commit.take_requirements();
475 let updates = commit.take_updates();
476 let request = CommitTableRequest {
477 identifier: commit.identifier().clone(),
478 requirements,
479 updates,
480 };
481 let request_str = serde_json::to_string(&request)?;
482
483 let request_jni_str = env.new_string(&request_str).with_context(|| {
484 format!("Failed to create jni string from request json: {request_str}.")
485 })?;
486
487 let result_json =
488 call_method!(env, inner.java_catalog.as_obj(), {String updateTable(String)},
489 &request_jni_str)
490 .with_context(|| {
491 format!("Failed to update iceberg table: {}", commit.identifier())
492 })?;
493
494 let rust_json_str = jobj_to_str(env, result_json)?;
495
496 let response: CommitTableResponse = serde_json::from_str(&rust_json_str)?;
497
498 tracing::info!(
499 "Table metadata location of {} is {}",
500 commit.identifier(),
501 response.metadata_location
502 );
503
504 let table_metadata = response.metadata;
505
506 let file_io = FileIO::from_path(&response.metadata_location)?
507 .with_props(file_io_props.iter())
508 .build()?;
509
510 Ok(Table::builder()
511 .file_io(file_io)
512 .identifier(commit.identifier().clone())
513 .metadata(table_metadata)
514 .build()?)
515 })
516 })
517 .await
518 .map_err(|e| {
519 iceberg::Error::new(
520 iceberg::ErrorKind::Unexpected,
521 "Failed to update iceberg table.",
522 )
523 .with_source(e)
524 })
525 }
526}
527
528impl Drop for JniCatalogInner {
529 fn drop(&mut self) {
530 let _ = execute_with_jni_env(self.jvm, |env| {
531 call_method!(env, self.java_catalog.as_obj(), {void close()})
532 .with_context(|| "Failed to close iceberg catalog".to_owned())?;
533 Ok(())
534 })
535 .inspect_err(
536 |e| tracing::error!(error = ?e.as_report(), "Failed to close iceberg catalog"),
537 );
538 }
539}
540
541impl JniCatalog {
542 fn build(
543 file_io_props: HashMap<String, String>,
544 name: impl ToString,
545 catalog_impl: impl ToString,
546 java_catalog_props: HashMap<String, String>,
547 ) -> ConnectorResult<Self> {
548 let jvm = Jvm::get_or_init()?;
549
550 execute_with_jni_env(jvm, |env| {
551 let props = env.new_object_array(
553 (java_catalog_props.len() * 2) as i32,
554 "java/lang/String",
555 JObject::null(),
556 )?;
557 for (i, (key, value)) in java_catalog_props.iter().enumerate() {
558 let key_j_str = env.new_string(key)?;
559 let value_j_str = env.new_string(value)?;
560 env.set_object_array_element(&props, i as i32 * 2, key_j_str)?;
561 env.set_object_array_element(&props, i as i32 * 2 + 1, value_j_str)?;
562 }
563
564 let jni_catalog_wrapper = env
565 .call_static_method(
566 "com/risingwave/connector/catalog/JniCatalogWrapper",
567 "create",
568 "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;)Lcom/risingwave/connector/catalog/JniCatalogWrapper;",
569 &[
570 (&env.new_string(name.to_string()).unwrap()).into(),
571 (&env.new_string(catalog_impl.to_string()).unwrap()).into(),
572 (&props).into(),
573 ],
574 )?;
575
576 let jni_catalog = env.new_global_ref(jni_catalog_wrapper.l().unwrap())?;
577
578 Ok(Self {
579 inner: Arc::new(JniCatalogInner {
580 java_catalog: jni_catalog,
581 jvm,
582 }),
583 file_io_props: Arc::new(file_io_props),
584 })
585 })
586 .map_err(Into::into)
587 }
588
589 pub async fn build_catalog(
590 file_io_props: HashMap<String, String>,
591 name: impl ToString + Send + 'static,
592 catalog_impl: impl ToString + Send + 'static,
593 java_catalog_props: HashMap<String, String>,
594 ) -> ConnectorResult<Arc<dyn Catalog>> {
595 let catalog = execute_blocking_jni(move || {
596 Ok(Self::build(
597 file_io_props,
598 name,
599 catalog_impl,
600 java_catalog_props,
601 )?)
602 })
603 .await?;
604 Ok(Arc::new(catalog) as Arc<dyn Catalog>)
605 }
606}