Skip to main content

risingwave_connector/connector_common/iceberg/
jni_catalog.rs

1// Copyright 2024 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! This module provide jni catalog.
16
17#![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::FileIOBuilder;
29use iceberg::spec::{Schema, SortOrder, TableMetadata, UnboundPartitionSpec};
30use iceberg::table::Table;
31use iceberg::{
32    Catalog, Namespace, NamespaceIdent, Runtime, TableCommit, TableCreation, TableIdent,
33    TableRequirement, TableUpdate,
34};
35use iceberg_storage_opendal::OpenDalResolvingStorageFactory;
36use itertools::Itertools;
37use jni::objects::{GlobalRef, JObject};
38use risingwave_common::global_jvm::Jvm;
39use risingwave_jni_core::call_method;
40use risingwave_jni_core::jvm_runtime::{execute_with_jni_env, jobj_to_str};
41use serde::{Deserialize, Serialize};
42use thiserror_ext::AsReport;
43
44use crate::error::ConnectorResult;
45
46#[derive(Debug, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48struct LoadTableResponse {
49    pub metadata_location: Option<String>,
50    pub metadata: TableMetadata,
51    pub _config: Option<HashMap<String, String>>,
52}
53
54#[derive(Debug, Serialize)]
55#[serde(rename_all = "kebab-case")]
56struct CreateTableRequest {
57    /// The name of the table.
58    pub name: String,
59    /// The location of the table.
60    pub location: Option<String>,
61    /// The schema of the table.
62    pub schema: Schema,
63    /// The partition spec of the table, could be None.
64    pub partition_spec: Option<UnboundPartitionSpec>,
65    /// The sort order of the table.
66    pub write_order: Option<SortOrder>,
67    /// The properties of the table.
68    pub properties: HashMap<String, String>,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72struct CommitTableRequest {
73    identifier: TableIdent,
74    requirements: Vec<TableRequirement>,
75    updates: Vec<TableUpdate>,
76}
77
78#[derive(Debug, Serialize, Deserialize)]
79#[serde(rename_all = "kebab-case")]
80struct CommitTableResponse {
81    metadata_location: String,
82    metadata: TableMetadata,
83}
84
85#[derive(Debug, Serialize, Deserialize)]
86#[serde(rename_all = "kebab-case")]
87struct ListNamespacesResponse {
88    namespaces: Vec<NamespaceIdent>,
89    next_page_token: Option<String>,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94struct ListTablesResponse {
95    identifiers: Vec<TableIdent>,
96    next_page_token: Option<String>,
97}
98
99impl From<&TableCreation> for CreateTableRequest {
100    fn from(value: &TableCreation) -> Self {
101        Self {
102            name: value.name.clone(),
103            location: value.location.clone(),
104            schema: value.schema.clone(),
105            partition_spec: value.partition_spec.clone(),
106            write_order: value.sort_order.clone(),
107            properties: value.properties.clone(),
108        }
109    }
110}
111
112fn namespace_to_string(namespace: &NamespaceIdent) -> String {
113    namespace.iter().join(".")
114}
115
116#[derive(Debug)]
117struct JniCatalogInner {
118    java_catalog: GlobalRef,
119    jvm: Jvm,
120}
121
122#[derive(Debug)]
123pub struct JniCatalog {
124    /// A blocking JNI operation can outlive its async caller after cancellation. Keep the Java
125    /// catalog alive until the last such operation finishes.
126    inner: Arc<JniCatalogInner>,
127    file_io_props: Arc<HashMap<String, String>>,
128}
129
130/// Iceberg's Java catalog API is synchronous and may perform remote catalog I/O. Running it
131/// directly in an async `Catalog` method would block a Tokio worker thread, so async JNI operations
132/// go through the runtime's blocking pool.
133async fn execute_blocking_jni<T>(
134    task: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
135) -> anyhow::Result<T>
136where
137    T: Send + 'static,
138{
139    tokio::task::spawn_blocking(task)
140        .await
141        .context("Failed to join blocking Iceberg JNI catalog task")?
142}
143
144#[async_trait]
145impl Catalog for JniCatalog {
146    /// List namespaces from the catalog.
147    async fn list_namespaces(
148        &self,
149        _parent: Option<&NamespaceIdent>,
150    ) -> iceberg::Result<Vec<NamespaceIdent>> {
151        let inner = self.inner.clone();
152        execute_blocking_jni(move || {
153            execute_with_jni_env(inner.jvm, |env| {
154                let result_json =
155                    call_method!(env, inner.java_catalog.as_obj(), {String listNamespaces()})
156                        .with_context(|| "Failed to list iceberg namespaces".to_owned())?;
157
158                let rust_json_str = jobj_to_str(env, result_json)?;
159
160                let resp: ListNamespacesResponse = serde_json::from_str(&rust_json_str)?;
161
162                Ok(resp.namespaces)
163            })
164        })
165        .await
166        .map_err(|e| {
167            iceberg::Error::new(
168                iceberg::ErrorKind::Unexpected,
169                "Failed to list iceberg namespaces.",
170            )
171            .with_source(e)
172        })
173    }
174
175    /// Create a new namespace inside the catalog.
176    async fn create_namespace(
177        &self,
178        namespace: &iceberg::NamespaceIdent,
179        _properties: HashMap<String, String>,
180    ) -> iceberg::Result<iceberg::Namespace> {
181        let inner = self.inner.clone();
182        let namespace = namespace.clone();
183        execute_blocking_jni(move || {
184            execute_with_jni_env(inner.jvm, |env| {
185                let namespace_str = namespace_to_string(&namespace);
186                let namespace_jstr = env.new_string(&namespace_str).unwrap();
187
188                call_method!(env, inner.java_catalog.as_obj(), {void createNamespace(String)},
189                    &namespace_jstr)
190                .with_context(|| format!("Failed to create namespace: {namespace}"))?;
191
192                Ok(Namespace::new(namespace))
193            })
194        })
195        .await
196        .map_err(|e| {
197            iceberg::Error::new(
198                iceberg::ErrorKind::Unexpected,
199                "Failed to create namespace.",
200            )
201            .with_source(e)
202        })
203    }
204
205    /// Get a namespace information from the catalog.
206    async fn get_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<Namespace> {
207        todo!()
208    }
209
210    /// Check if namespace exists in catalog.
211    async fn namespace_exists(&self, namespace: &NamespaceIdent) -> iceberg::Result<bool> {
212        let inner = self.inner.clone();
213        let namespace = namespace.clone();
214        execute_blocking_jni(move || {
215            execute_with_jni_env(inner.jvm, |env| {
216                let namespace_str = namespace_to_string(&namespace);
217                let namespace_jstr = env.new_string(&namespace_str).unwrap();
218
219                let exists =
220                    call_method!(env, inner.java_catalog.as_obj(), {boolean namespaceExists(String)},
221                    &namespace_jstr)
222                    .with_context(|| format!("Failed to check namespace exists: {namespace}"))?;
223
224                Ok(exists)
225            })
226        })
227        .await
228        .map_err(|e| {
229            iceberg::Error::new(
230                iceberg::ErrorKind::Unexpected,
231                "Failed to check namespace exists.",
232            )
233            .with_source(e)
234        })
235    }
236
237    /// Drop a namespace from the catalog.
238    async fn drop_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<()> {
239        todo!()
240    }
241
242    /// List tables from namespace.
243    async fn list_tables(&self, namespace: &NamespaceIdent) -> iceberg::Result<Vec<TableIdent>> {
244        let inner = self.inner.clone();
245        let namespace = namespace.clone();
246        execute_blocking_jni(move || {
247            execute_with_jni_env(inner.jvm, |env| {
248                let namespace_str = namespace_to_string(&namespace);
249                let namespace_jstr = env.new_string(&namespace_str).unwrap();
250
251                let result_json =
252                    call_method!(env, inner.java_catalog.as_obj(), {String listTables(String)},
253                    &namespace_jstr)
254                    .with_context(|| {
255                        format!("Failed to list iceberg tables in namespace: {}", namespace)
256                    })?;
257
258                let rust_json_str = jobj_to_str(env, result_json)?;
259
260                let resp: ListTablesResponse = serde_json::from_str(&rust_json_str)?;
261
262                Ok(resp.identifiers)
263            })
264        })
265        .await
266        .map_err(|e| {
267            iceberg::Error::new(
268                iceberg::ErrorKind::Unexpected,
269                "Failed to list iceberg  tables.",
270            )
271            .with_source(e)
272        })
273    }
274
275    async fn update_namespace(
276        &self,
277        _namespace: &NamespaceIdent,
278        _properties: HashMap<String, String>,
279    ) -> iceberg::Result<()> {
280        todo!()
281    }
282
283    /// Create a new table inside the namespace.
284    async fn create_table(
285        &self,
286        namespace: &NamespaceIdent,
287        creation: TableCreation,
288    ) -> iceberg::Result<Table> {
289        let inner = self.inner.clone();
290        let file_io_props = self.file_io_props.clone();
291        let runtime = Runtime::try_current()?;
292        let namespace = namespace.clone();
293        execute_blocking_jni(move || {
294            execute_with_jni_env(inner.jvm, |env| {
295                let namespace_str = namespace_to_string(&namespace);
296                let namespace_jstr = env.new_string(&namespace_str).unwrap();
297
298                let creation_str = serde_json::to_string(&CreateTableRequest::from(&creation))?;
299
300                let creation_jstr = env.new_string(&creation_str).unwrap();
301
302                let result_json =
303                    call_method!(env, inner.java_catalog.as_obj(), {String createTable(String, String)},
304                    &namespace_jstr, &creation_jstr)
305                    .with_context(|| {
306                        format!("Failed to create iceberg table: {}", creation.name)
307                    })?;
308
309                let rust_json_str = jobj_to_str(env, result_json)?;
310
311                let resp: LoadTableResponse = serde_json::from_str(&rust_json_str)?;
312
313                let _metadata_location = resp.metadata_location.ok_or_else(|| {
314                    iceberg::Error::new(
315                        iceberg::ErrorKind::FeatureUnsupported,
316                        "Loading uncommitted table is not supported!",
317                    )
318                })?;
319
320                let table_metadata = resp.metadata;
321
322                let file_io =
323                    FileIOBuilder::new(Arc::new(OpenDalResolvingStorageFactory::new()))
324                    .with_props(file_io_props.iter())
325                    .build();
326
327                Ok(Table::builder()
328                    .file_io(file_io)
329                    .identifier(TableIdent::new(namespace, creation.name))
330                    .metadata(table_metadata)
331                    .runtime(runtime)
332                    .build())
333            })
334        })
335        .await
336        .map_err(|e| {
337            iceberg::Error::new(
338                iceberg::ErrorKind::Unexpected,
339                "Failed to create iceberg table.",
340            )
341            .with_source(e)
342        })?
343    }
344
345    /// Load table from the catalog.
346    async fn load_table(&self, table: &TableIdent) -> iceberg::Result<Table> {
347        let inner = self.inner.clone();
348        let file_io_props = self.file_io_props.clone();
349        let runtime = Runtime::try_current()?;
350        let table = table.clone();
351        execute_blocking_jni(move || {
352            execute_with_jni_env(inner.jvm, |env| {
353                let table_name_str = table.to_string();
354
355                let table_name_jstr = env.new_string(&table_name_str).unwrap();
356
357                let result_json =
358                    call_method!(env, inner.java_catalog.as_obj(), {String loadTable(String)},
359                    &table_name_jstr)
360                    .with_context(|| format!("Failed to load iceberg table: {table_name_str}"))?;
361
362                let rust_json_str = jobj_to_str(env, result_json)?;
363
364                let resp: LoadTableResponse = serde_json::from_str(&rust_json_str)?;
365
366                let metadata_location = resp.metadata_location.ok_or_else(|| {
367                    iceberg::Error::new(
368                        iceberg::ErrorKind::FeatureUnsupported,
369                        "Loading uncommitted table is not supported!",
370                    )
371                })?;
372
373                tracing::info!(
374                    "Table metadata location of {table_name_str} is {metadata_location}"
375                );
376
377                let table_metadata = resp.metadata;
378
379                let file_io = FileIOBuilder::new(Arc::new(OpenDalResolvingStorageFactory::new()))
380                    .with_props(file_io_props.iter())
381                    .build();
382
383                Ok(Table::builder()
384                    .file_io(file_io)
385                    .identifier(table)
386                    .metadata(table_metadata)
387                    .runtime(runtime)
388                    .build())
389            })
390        })
391        .await
392        .map_err(|e| {
393            iceberg::Error::new(
394                iceberg::ErrorKind::Unexpected,
395                "Failed to load iceberg table.",
396            )
397            .with_source(e)
398        })?
399    }
400
401    /// Drop a table from the catalog.
402    async fn drop_table(&self, table: &TableIdent) -> iceberg::Result<()> {
403        let inner = self.inner.clone();
404        let table = table.to_owned();
405        execute_blocking_jni(move || {
406            execute_with_jni_env(inner.jvm, |env| {
407                let table_name_str = table.to_string();
408
409                let table_name_jstr = env.new_string(&table_name_str).unwrap();
410
411                call_method!(env, inner.java_catalog.as_obj(), {boolean dropTable(String)},
412                &table_name_jstr)
413                .with_context(|| format!("Failed to drop iceberg table: {table_name_str}"))?;
414
415                Ok(())
416            })
417        })
418        .await
419        .map_err(|e| {
420            iceberg::Error::new(
421                iceberg::ErrorKind::Unexpected,
422                "Failed to drop iceberg table.",
423            )
424            .with_source(e)
425        })
426    }
427
428    async fn purge_table(&self, table: &TableIdent) -> iceberg::Result<()> {
429        let table_info = self.load_table(table).await?;
430        self.drop_table(table).await?;
431        iceberg::drop_table_data(&table_info).await
432    }
433
434    async fn register_table(
435        &self,
436        _table_ident: &TableIdent,
437        _metadata_location: String,
438    ) -> iceberg::Result<Table> {
439        Err(iceberg::Error::new(
440            iceberg::ErrorKind::Unexpected,
441            "register_table is not supported by JniCatalog",
442        ))
443    }
444
445    /// Check if a table exists in the catalog.
446    async fn table_exists(&self, table: &TableIdent) -> iceberg::Result<bool> {
447        let inner = self.inner.clone();
448        let table = table.clone();
449        execute_blocking_jni(move || {
450            execute_with_jni_env(inner.jvm, |env| {
451                let table_name_str = table.to_string();
452
453                let table_name_jstr = env.new_string(&table_name_str).unwrap();
454
455                let exists =
456                    call_method!(env, inner.java_catalog.as_obj(), {boolean tableExists(String)},
457                    &table_name_jstr)
458                    .with_context(|| {
459                        format!("Failed to check iceberg table exists: {table_name_str}")
460                    })?;
461
462                Ok(exists)
463            })
464        })
465        .await
466        .map_err(|e| {
467            iceberg::Error::new(
468                iceberg::ErrorKind::Unexpected,
469                "Failed to check iceberg table exists.",
470            )
471            .with_source(e)
472        })
473    }
474
475    /// Rename a table in the catalog.
476    async fn rename_table(&self, _src: &TableIdent, _dest: &TableIdent) -> iceberg::Result<()> {
477        todo!()
478    }
479
480    /// Update a table to the catalog.
481    async fn update_table(&self, mut commit: TableCommit) -> iceberg::Result<Table> {
482        let inner = self.inner.clone();
483        let file_io_props = self.file_io_props.clone();
484        let runtime = Runtime::try_current()?;
485        execute_blocking_jni(move || {
486            execute_with_jni_env(inner.jvm, |env| {
487                let requirements = commit.take_requirements();
488                let updates = commit.take_updates();
489                let request = CommitTableRequest {
490                    identifier: commit.identifier().clone(),
491                    requirements,
492                    updates,
493                };
494                let request_str = serde_json::to_string(&request)?;
495
496                let request_jni_str = env.new_string(&request_str).with_context(|| {
497                    format!("Failed to create jni string from request json: {request_str}.")
498                })?;
499
500                let result_json =
501                    call_method!(env, inner.java_catalog.as_obj(), {String updateTable(String)},
502                    &request_jni_str)
503                    .with_context(|| {
504                        format!("Failed to update iceberg table: {}", commit.identifier())
505                    })?;
506
507                let rust_json_str = jobj_to_str(env, result_json)?;
508
509                let response: CommitTableResponse = serde_json::from_str(&rust_json_str)?;
510
511                tracing::info!(
512                    "Table metadata location of {} is {}",
513                    commit.identifier(),
514                    response.metadata_location
515                );
516
517                let table_metadata = response.metadata;
518
519                let file_io = FileIOBuilder::new(Arc::new(OpenDalResolvingStorageFactory::new()))
520                    .with_props(file_io_props.iter())
521                    .build();
522
523                Ok(Table::builder()
524                    .file_io(file_io)
525                    .identifier(commit.identifier().clone())
526                    .metadata(table_metadata)
527                    .runtime(runtime)
528                    .build()?)
529            })
530        })
531        .await
532        .map_err(|e| {
533            iceberg::Error::new(
534                iceberg::ErrorKind::Unexpected,
535                "Failed to update iceberg table.",
536            )
537            .with_source(e)
538        })
539    }
540}
541
542impl Drop for JniCatalogInner {
543    fn drop(&mut self) {
544        let _ = execute_with_jni_env(self.jvm, |env| {
545            call_method!(env, self.java_catalog.as_obj(), {void close()})
546                .with_context(|| "Failed to close iceberg catalog".to_owned())?;
547            Ok(())
548        })
549        .inspect_err(
550            |e| tracing::error!(error = ?e.as_report(), "Failed to close iceberg catalog"),
551        );
552    }
553}
554
555impl JniCatalog {
556    fn build(
557        file_io_props: HashMap<String, String>,
558        name: impl ToString,
559        catalog_impl: impl ToString,
560        java_catalog_props: HashMap<String, String>,
561    ) -> ConnectorResult<Self> {
562        let jvm = Jvm::get_or_init()?;
563
564        execute_with_jni_env(jvm, |env| {
565            // Convert props to string array
566            let props = env.new_object_array(
567                (java_catalog_props.len() * 2) as i32,
568                "java/lang/String",
569                JObject::null(),
570            )?;
571            for (i, (key, value)) in java_catalog_props.iter().enumerate() {
572                let key_j_str = env.new_string(key)?;
573                let value_j_str = env.new_string(value)?;
574                env.set_object_array_element(&props, i as i32 * 2, key_j_str)?;
575                env.set_object_array_element(&props, i as i32 * 2 + 1, value_j_str)?;
576            }
577
578            let jni_catalog_wrapper = env
579                .call_static_method(
580                    "com/risingwave/connector/catalog/JniCatalogWrapper",
581                    "create",
582                    "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;)Lcom/risingwave/connector/catalog/JniCatalogWrapper;",
583                    &[
584                        (&env.new_string(name.to_string()).unwrap()).into(),
585                        (&env.new_string(catalog_impl.to_string()).unwrap()).into(),
586                        (&props).into(),
587                    ],
588                )?;
589
590            let jni_catalog = env.new_global_ref(jni_catalog_wrapper.l().unwrap())?;
591
592            Ok(Self {
593                inner: Arc::new(JniCatalogInner {
594                    java_catalog: jni_catalog,
595                    jvm,
596                }),
597                file_io_props: Arc::new(file_io_props),
598            })
599        })
600            .map_err(Into::into)
601    }
602
603    pub async fn build_catalog(
604        file_io_props: HashMap<String, String>,
605        name: impl ToString + Send + 'static,
606        catalog_impl: impl ToString + Send + 'static,
607        java_catalog_props: HashMap<String, String>,
608    ) -> ConnectorResult<Arc<dyn Catalog>> {
609        let catalog = execute_blocking_jni(move || {
610            Ok(Self::build(
611                file_io_props,
612                name,
613                catalog_impl,
614                java_catalog_props,
615            )?)
616        })
617        .await?;
618        Ok(Arc::new(catalog) as Arc<dyn Catalog>)
619    }
620}