This is an automated email from the ASF dual-hosted git repository.
jerry-024 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-vector-index.git
The following commit(s) were added to refs/heads/main by this push:
new ee17bfd feat(logging): bridge native IVF-PQ diagnostics to SLF4J (#75)
ee17bfd is described below
commit ee17bfde5028e5d46aacc72192d078daab7eceae
Author: jerry <[email protected]>
AuthorDate: Thu Aug 13 09:38:58 2026 +0800
feat(logging): bridge native IVF-PQ diagnostics to SLF4J (#75)
---
.github/workflows/ci.yml | 8 +-
core/src/io.rs | 68 ++++-
core/src/ivfpq.rs | 325 ++++++++++++++++++++-
core/src/lib.rs | 1 +
core/src/logging.rs | 93 ++++++
core/src/sparse_table.rs | 8 +
java/pom.xml | 42 +++
.../paimon/index/vector/NativeLogBridge.java | 62 ++++
.../index/vector/NativeLogBridgeSmokeTest.java | 202 +++++++++++++
jni/src/lib.rs | 1 +
jni/src/log_bridge.rs | 130 +++++++++
11 files changed, 925 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8b61d8..edeae96 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -191,12 +191,14 @@ jobs:
java-version: '8'
distribution: 'temurin'
- - name: Test Java API
- run: mvn -f java/pom.xml test
-
- name: Build JNI library
run: cargo build -p paimon-vindex-jni --release
+ - name: Test Java API
+ run: >
+ mvn -f java/pom.xml test
+ -Dpaimon.vindex.native.path="${{ github.workspace
}}/target/release/libpaimon_vindex_jni.so"
+
- name: Test JNI native behavior
run: |
java -cp java/target/test-classes:java/target/classes
org.apache.paimon.index.vector.VectorIndexNativeValidationTest \
diff --git a/core/src/io.rs b/core/src/io.rs
index 87b7dd3..29fa69a 100644
--- a/core/src/io.rs
+++ b/core/src/io.rs
@@ -27,6 +27,7 @@ use crate::pq::ProductQuantizer;
use rayon::prelude::*;
use std::io;
use std::mem::size_of;
+use std::time::{Duration, Instant};
pub const MAGIC: u32 = 0x49565051; // "IVPQ"
pub const VERSION: u32 = 1;
@@ -95,6 +96,56 @@ pub trait SeekRead: Send {
}
}
+#[derive(Clone, Copy, Debug, Default)]
+pub(crate) struct ReadMetrics {
+ pub elapsed: Duration,
+ pub calls: usize,
+ pub requested_bytes: usize,
+}
+
+struct MeasuredSeekRead<R> {
+ inner: R,
+ metrics: Option<ReadMetrics>,
+}
+
+impl<R> MeasuredSeekRead<R> {
+ fn new(inner: R) -> Self {
+ Self {
+ inner,
+ metrics: None,
+ }
+ }
+}
+
+impl<R: SeekRead> SeekRead for MeasuredSeekRead<R> {
+ fn pread(&mut self, ranges: &mut [ReadRequest<'_>]) -> io::Result<()> {
+ let measurement = self.metrics.as_ref().map(|_| {
+ let requested_bytes = ranges.iter().fold(0usize, |total, request| {
+ total.saturating_add(request.buf.len())
+ });
+ (Instant::now(), requested_bytes)
+ });
+ let result = self.inner.pread(ranges);
+ if let (Some(metrics), Some((started, requested_bytes))) =
+ (self.metrics.as_mut(), measurement)
+ {
+ metrics.elapsed += started.elapsed();
+ metrics.calls = metrics.calls.saturating_add(1);
+ metrics.requested_bytes =
metrics.requested_bytes.saturating_add(requested_bytes);
+ }
+ result
+ }
+
+ fn try_clone_reader(&self) -> io::Result<Option<Self>> {
+ // Clones start with metrics disabled, so their I/O is not included
here.
+ Ok(self.inner.try_clone_reader()?.map(Self::new))
+ }
+
+ fn read_capabilities(&self) -> SeekReadCapabilities {
+ self.inner.read_capabilities()
+ }
+}
+
pub(crate) struct PreadCursor<'a, R: SeekRead + ?Sized> {
reader: &'a mut R,
pos: u64,
@@ -456,7 +507,7 @@ fn u64_to_i64(value: u64, field: &str) -> io::Result<i64> {
// --- Reader ---
pub struct IVFPQIndexReader<R: SeekRead> {
- reader: R,
+ reader: MeasuredSeekRead<R>,
pub d: usize,
pub nlist: usize,
pub m: usize,
@@ -578,7 +629,7 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
let has_opq = flags & FLAG_HAS_OPQ != 0;
Ok(IVFPQIndexReader {
- reader,
+ reader: MeasuredSeekRead::new(reader),
d,
nlist,
m,
@@ -609,6 +660,14 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
})
}
+ pub(crate) fn begin_read_metrics(&mut self) {
+ self.reader.metrics = Some(ReadMetrics::default());
+ }
+
+ pub(crate) fn end_read_metrics(&mut self) -> ReadMetrics {
+ self.reader.metrics.take().unwrap_or_default()
+ }
+
/// Load centroids, codebooks, and offset table. Called automatically on
first search.
pub fn ensure_loaded(&mut self) -> io::Result<()> {
if self.loaded {
@@ -1396,9 +1455,12 @@ mod tests {
reader.list_id_bytes_lens[non_empty_list] > 0,
"v1 files must store id_bytes_len in the offset table"
);
+ let expected_requested_bytes =
reader.list_payload_len(non_empty_list).unwrap();
+ reader.begin_read_metrics();
let mut lists = reader
.read_inverted_list_payloads(&[non_empty_list])
.unwrap();
+ let read_metrics = reader.end_read_metrics();
let list = lists.pop().unwrap();
let read_ids = &list.ids;
let codes = list.codes();
@@ -1416,6 +1478,8 @@ mod tests {
stats.pread_calls, 1,
"delta-varint lists with offset-table id length should use one
pread"
);
+ assert_eq!(read_metrics.calls, 1);
+ assert_eq!(read_metrics.requested_bytes, expected_requested_bytes);
}
#[test]
diff --git a/core/src/ivfpq.rs b/core/src/ivfpq.rs
index 7cfd70f..e45647f 100644
--- a/core/src/ivfpq.rs
+++ b/core/src/ivfpq.rs
@@ -22,14 +22,17 @@ use crate::distance::{
use crate::index_io_util::ivf_payload_is_oversized;
use crate::io::{IVFPQIndexReader, InvertedListPayload, SeekRead};
use crate::kmeans::{self, KMeansConfig};
+use crate::logging::{emit_log, LogLevel};
use crate::opq::OPQMatrix;
use crate::pq::ProductQuantizer;
+use crate::sparse_table::SparseTable;
use rayon::prelude::*;
use roaring::RoaringTreemap;
use std::borrow::Cow;
use std::collections::HashSet;
use std::io;
use std::sync::OnceLock;
+use std::time::{Duration, Instant};
pub trait RowIdFilter: Sync {
fn contains(&self, id: i64) -> bool;
@@ -1722,6 +1725,131 @@ fn scan_reader_codes(
}
}
+#[derive(Default)]
+struct IvfpqBatchTiming {
+ load: Duration,
+ preprocess: Duration,
+ coarse: Duration,
+ prepare: Duration,
+ io_read: Duration,
+ decode: Duration,
+ filter: Duration,
+ scan: Duration,
+ finalize: Duration,
+ read_calls: usize,
+ requested_bytes: usize,
+ unique_list_rows: usize,
+ query_list_pairs: usize,
+ pq_codes_evaluated: usize,
+ sparse_query_list_pairs: usize,
+ dense_query_list_pairs: usize,
+ actual_pq_codes_evaluated: usize,
+ matched_rows: usize,
+ queries_below_k: usize,
+ min_hits_per_query: usize,
+}
+
+impl IvfpqBatchTiming {
+ fn record_scan_work(
+ &mut self,
+ rows: usize,
+ matching_rows: Option<&MatchingRows>,
+ query_uses: usize,
+ pq_bits: usize,
+ transposed_codes: bool,
+ ) {
+ let matched_rows = matching_rows.map_or(rows, MatchingRows::len);
+ self.unique_list_rows = self.unique_list_rows.saturating_add(rows);
+ self.matched_rows = self.matched_rows.saturating_add(matched_rows);
+ self.pq_codes_evaluated = self
+ .pq_codes_evaluated
+ .saturating_add(matched_rows.saturating_mul(query_uses));
+
+ if matched_rows == 0 || query_uses == 0 {
+ return;
+ }
+ let sparse = match (pq_bits, transposed_codes, matching_rows) {
+ (4, _, _) | (_, _, None) => false,
+ (_, true, Some(matching_rows)) => {
+ should_scan_sparse(rows, matching_rows,
TRANSPOSED_SPARSE_SCAN_DIVISOR)
+ }
+ (_, false, Some(matching_rows)) => {
+ should_scan_sparse(rows, matching_rows,
ROW_MAJOR_SPARSE_SCAN_DIVISOR)
+ }
+ };
+ let scan_rows = if sparse { matched_rows } else { rows };
+ self.actual_pq_codes_evaluated = self
+ .actual_pq_codes_evaluated
+ .saturating_add(scan_rows.saturating_mul(query_uses));
+ if sparse {
+ self.sparse_query_list_pairs =
self.sparse_query_list_pairs.saturating_add(query_uses);
+ } else {
+ self.dense_query_list_pairs =
self.dense_query_list_pairs.saturating_add(query_uses);
+ }
+ }
+
+ fn write_to<W: io::Write>(
+ &self,
+ mut output: W,
+ total: Duration,
+ nq: usize,
+ nprobe: usize,
+ pq_bits: usize,
+ topk: usize,
+ unique_lists: usize,
+ filtered: bool,
+ ) -> io::Result<()> {
+ let millis = |duration: Duration| duration.as_secs_f64() * 1_000.0;
+ writeln!(
+ output,
+ "[paimon-vindex] ivfpq_batch_timing nq={nq} nprobe={nprobe}
pq_bits={pq_bits} \
+ topk={topk} unique_lists={unique_lists} unique_list_rows={}
query_list_pairs={} \
+ pq_codes_evaluated={} sparse_query_list_pairs={}
dense_query_list_pairs={} \
+ actual_pq_codes_evaluated={} matched_rows={} read_calls={}
requested_bytes={} \
+ queries_below_k={} min_hits_per_query={} filtered={filtered}
total_ms={:.3} \
+ load_ms={:.3} preprocess_ms={:.3} coarse_ms={:.3}
prepare_ms={:.3} \
+ io_read_ms={:.3} decode_ms={:.3} filter_ms={:.3} scan_ms={:.3}
finalize_ms={:.3}",
+ self.unique_list_rows,
+ self.query_list_pairs,
+ self.pq_codes_evaluated,
+ self.sparse_query_list_pairs,
+ self.dense_query_list_pairs,
+ self.actual_pq_codes_evaluated,
+ self.matched_rows,
+ self.read_calls,
+ self.requested_bytes,
+ self.queries_below_k,
+ self.min_hits_per_query,
+ millis(total),
+ millis(self.load),
+ millis(self.preprocess),
+ millis(self.coarse),
+ millis(self.prepare),
+ millis(self.io_read),
+ millis(self.decode),
+ millis(self.filter),
+ millis(self.scan),
+ millis(self.finalize),
+ )
+ }
+}
+
+#[inline]
+fn elapsed_since(started: Option<Instant>) -> Duration {
+ started.map_or(Duration::ZERO, |started| started.elapsed())
+}
+
+fn end_read_metrics_on_error<R: SeekRead, T>(
+ reader: &mut IVFPQIndexReader<R>,
+ result: io::Result<T>,
+ timing_enabled: bool,
+) -> io::Result<T> {
+ if timing_enabled && result.is_err() {
+ let _ = reader.end_read_metrics();
+ }
+ result
+}
+
/// Big batch search: batch queries share list reads.
/// Instead of nq*nprobe I/O ops, reads each unique list once and scans for
all queries.
pub fn search_batch_reader<R: SeekRead>(
@@ -1949,7 +2077,12 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
mut observe_ephemeral_precomputed_lists: impl FnMut(usize),
#[cfg(test)] distance_table_builds:
Option<&std::sync::atomic::AtomicUsize>,
) -> io::Result<(Vec<i64>, Vec<f32>)> {
+ let timing_enabled =
std::env::var_os("PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING").is_some();
+ let total_started = timing_enabled.then(Instant::now);
+ let mut timing = IvfpqBatchTiming::default();
+ let load_started = timing_enabled.then(Instant::now);
reader.ensure_loaded()?;
+ timing.load = elapsed_since(load_started);
let d = reader.d;
if nq == 0 {
return Err(io::Error::new(
@@ -1985,6 +2118,7 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
"probe range must be non-empty",
));
}
+ let scanned_nprobe = probe_end - probe_start;
validate_batch_seed(seed_ids, seed_distances, nq, k)?;
let m = reader.m;
@@ -1993,6 +2127,7 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
let by_residual = reader.by_residual;
// Step 1: Preprocess all queries
+ let preprocess_started = timing_enabled.then(Instant::now);
let mut processed = queries[..nq * d].to_vec();
if metric == MetricType::Cosine {
for i in 0..nq {
@@ -2004,8 +2139,10 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
opq.apply_batch(&processed, &mut rotated, nq);
processed = rotated;
}
+ timing.preprocess = elapsed_since(preprocess_started);
// Step 2: Batch coarse search (one sgemm)
+ let coarse_started = timing_enabled.then(Instant::now);
let (all_probe_indices, all_coarse_dists) = kmeans::find_topk_batch(
&processed,
nq,
@@ -2014,13 +2151,28 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
d,
probe_end,
);
+ timing.coarse = elapsed_since(coarse_started);
// Step 3: Read every probed list once. Queries share the decoded list
// payloads, then scan independently in parallel.
+ let prepare_started = timing_enabled.then(Instant::now);
let mut seen = vec![false; reader.nlist];
let mut unique_lists = Vec::new();
+ let mut query_uses_by_list = timing_enabled
+ .then(||
SparseTable::<usize>::with_capacity(scanned_nprobe.min(reader.nlist)));
for probe_indices in &all_probe_indices {
for &list_id in probe_indices.iter().skip(probe_start) {
+ if let Some(query_uses) = query_uses_by_list.as_mut() {
+ timing.query_list_pairs =
timing.query_list_pairs.saturating_add(1);
+ if reader.list_counts[list_id] > 0 {
+ let key = list_id as u32;
+ if let Some(uses) = query_uses.get_mut(key) {
+ *uses = uses.saturating_add(1);
+ } else {
+ let _ = query_uses.insert(key, 1);
+ }
+ }
+ }
if !seen[list_id] && reader.list_counts[list_id] > 0 {
seen[list_id] = true;
unique_lists.push(list_id);
@@ -2094,13 +2246,20 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
Vec::new()
};
let mut stable_pq_norms = None;
+ timing.prepare = elapsed_since(prepare_started);
let mut heaps = (0..nq).map(|_| TopKHeap::new(k)).collect::<Vec<_>>();
seed_heaps(&mut heaps, seed_ids, seed_distances, k);
+ if timing_enabled {
+ reader.begin_read_metrics();
+ }
let mut batch_start = 0usize;
while batch_start < unique_lists.len() {
let first_list = unique_lists[batch_start];
- if ivf_payload_is_oversized(reader.list_payload_len(first_list)?) {
+ let payload_len = reader.list_payload_len(first_list);
+ let payload_len = end_read_metrics_on_error(reader, payload_len,
timing_enabled)?;
+ if ivf_payload_is_oversized(payload_len) {
+ let prepare_started = timing_enabled.then(Instant::now);
let query_tables = (0..nq)
.filter_map(|query_index| {
all_probe_indices[query_index]
@@ -2139,11 +2298,27 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
// The loop is sequential across queries. Reuse one chunk-sized
// distance buffer instead of retaining one per query.
let mut distances = Vec::new();
- reader.for_each_streamed_list_chunk(first_list, |pq, ids, codes| {
+ timing.prepare += elapsed_since(prepare_started);
+ let streamed_started = timing_enabled.then(Instant::now);
+ let mut streamed_filter = Duration::ZERO;
+ let mut streamed_scan = Duration::ZERO;
+ let result = reader.for_each_streamed_list_chunk(first_list, |pq,
ids, codes| {
+ let filter_started = timing_enabled.then(Instant::now);
let positions = matching_rows(ids, filter);
+ streamed_filter += elapsed_since(filter_started);
+ if timing_enabled {
+ timing.record_scan_work(
+ ids.len(),
+ positions.as_ref(),
+ query_tables.len(),
+ pq_nbits,
+ transposed_codes,
+ );
+ }
if positions.as_ref().is_some_and(MatchingRows::is_empty) {
return;
}
+ let scan_started = timing_enabled.then(Instant::now);
for (query_index, dis0, sim_table) in &query_tables {
let sim_table = sim_table.as_deref().unwrap_or_else(|| {
shared_sim_tables[*query_index].get_or_init(|| {
@@ -2174,22 +2349,52 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
&mut heaps[*query_index],
);
}
- })?;
+ streamed_scan += elapsed_since(scan_started);
+ });
+ end_read_metrics_on_error(reader, result, timing_enabled)?;
+ let streamed_total = elapsed_since(streamed_started);
+ timing.filter += streamed_filter;
+ timing.scan += streamed_scan;
+ // Keep I/O plus decode here; measured I/O is removed below for
both read paths.
+ timing.decode += streamed_total.saturating_sub(streamed_filter +
streamed_scan);
batch_start += 1;
continue;
}
- let count =
reader.batch_read_end(&unique_lists[batch_start..])?.max(1);
+ let batch_end_result =
reader.batch_read_end(&unique_lists[batch_start..]);
+ let count = end_read_metrics_on_error(reader, batch_end_result,
timing_enabled)?.max(1);
let batch_end = (batch_start + count).min(unique_lists.len());
- let loaded_lists =
-
reader.read_inverted_list_payloads(&unique_lists[batch_start..batch_end])?;
+ let read_decode_started = timing_enabled.then(Instant::now);
+ let loaded_lists_result =
+
reader.read_inverted_list_payloads(&unique_lists[batch_start..batch_end]);
+ let loaded_lists = end_read_metrics_on_error(reader,
loaded_lists_result, timing_enabled)?;
+ timing.decode += elapsed_since(read_decode_started);
+ let prepare_started = timing_enabled.then(Instant::now);
let mut list_positions = vec![usize::MAX; reader.nlist];
for (position, list) in loaded_lists.iter().enumerate() {
list_positions[list.list_id] = position;
}
+ timing.prepare += elapsed_since(prepare_started);
+ let filter_started = timing_enabled.then(Instant::now);
let matching_rows_by_list = loaded_lists
.iter()
.map(|list| matching_rows(&list.ids, filter))
.collect::<Vec<_>>();
+ timing.filter += elapsed_since(filter_started);
+ if let Some(query_uses) = query_uses_by_list.as_ref() {
+ for (list, matching_rows) in
loaded_lists.iter().zip(&matching_rows_by_list) {
+ timing.record_scan_work(
+ list.ids.len(),
+ matching_rows.as_ref(),
+ query_uses
+ .get(list.list_id as u32)
+ .copied()
+ .unwrap_or_default(),
+ reader.pq.nbits,
+ reader.transposed_codes,
+ );
+ }
+ }
+ let scan_started = timing_enabled.then(Instant::now);
let matching_list_count = matching_rows_by_list
.iter()
.filter(|rows| has_matching_rows(rows.as_ref()))
@@ -2354,29 +2559,59 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
heaps[qi].push(distance, row_id);
}
}
+ timing.scan += elapsed_since(scan_started);
batch_start = batch_end;
}
+ if timing_enabled {
+ let read_metrics = reader.end_read_metrics();
+ timing.io_read = read_metrics.elapsed;
+ // Both batch and streamed reads accumulated I/O plus decode above.
+ timing.decode = timing.decode.saturating_sub(timing.io_read);
+ timing.read_calls = read_metrics.calls;
+ timing.requested_bytes = read_metrics.requested_bytes;
+ }
+ let finalize_started = timing_enabled.then(Instant::now);
let mut result_ids = vec![-1i64; nq * k];
let mut result_dists = vec![f32::MAX; nq * k];
+ timing.min_hits_per_query = k;
for (qi, heap) in heaps.into_iter().enumerate() {
let sorted = heap.into_sorted();
+ if timing_enabled {
+ timing.queries_below_k = timing
+ .queries_below_k
+ .saturating_add(usize::from(sorted.len() < k));
+ timing.min_hits_per_query =
timing.min_hits_per_query.min(sorted.len());
+ }
let base = qi * k;
for (i, &(dist, id)) in sorted.iter().enumerate() {
result_ids[base + i] = id;
result_dists[base + i] = dist;
}
}
+ timing.finalize = elapsed_since(finalize_started);
- if !by_residual &&
std::env::var_os("PAIMON_VINDEX_LOG_IVFPQ_BATCH_REUSE").is_some() {
- use std::io::Write;
+ if timing_enabled {
+ let mut buf = Vec::with_capacity(256);
+ let _ = timing.write_to(
+ &mut buf,
+ elapsed_since(total_started),
+ nq,
+ scanned_nprobe,
+ reader.pq.nbits,
+ k,
+ unique_lists.len(),
+ filter.is_some(),
+ );
+ emit_log(LogLevel::Info, String::from_utf8_lossy(&buf).trim_end());
+ }
+ if !by_residual &&
std::env::var_os("PAIMON_VINDEX_LOG_IVFPQ_BATCH_REUSE").is_some() {
let tables_built = shared_sim_tables
.iter()
.filter(|table| table.get().is_some())
.count();
- let _ = writeln!(
- std::io::stderr().lock(),
+ let message = format!(
"[paimon-vindex] ivfpq_batch_table_reuse
strategy=non_residual_query_table \
mode={reuse_mode:?} enabled={reuse_non_residual_tables} used={}
metric={} \
pq_bits={} nq={nq} nprobe={probe_end} unique_lists={} filtered={}
required_bytes={:?} \
@@ -2388,6 +2623,7 @@ fn
search_batch_reader_filter_with_reuse_mode_and_observer<R: SeekRead>(
filter.is_some(),
reuse_required_bytes,
);
+ emit_log(LogLevel::Info, &message);
}
Ok((result_ids, result_dists))
@@ -3338,6 +3574,75 @@ mod tests {
assert_eq!(actual, expected);
}
+ #[test]
+ fn ivfpq_batch_timing_output_names_search_phases() {
+ let timing = IvfpqBatchTiming {
+ load: std::time::Duration::from_millis(1),
+ preprocess: std::time::Duration::from_millis(2),
+ coarse: std::time::Duration::from_millis(3),
+ prepare: std::time::Duration::from_millis(4),
+ io_read: std::time::Duration::from_millis(5),
+ decode: std::time::Duration::from_millis(6),
+ filter: std::time::Duration::from_millis(7),
+ scan: std::time::Duration::from_millis(8),
+ finalize: std::time::Duration::from_millis(9),
+ read_calls: 10,
+ requested_bytes: 4096,
+ unique_list_rows: 120,
+ query_list_pairs: 512,
+ pq_codes_evaluated: 240,
+ sparse_query_list_pairs: 128,
+ dense_query_list_pairs: 384,
+ actual_pq_codes_evaluated: 960,
+ matched_rows: 30,
+ queries_below_k: 2,
+ min_hits_per_query: 1,
+ };
+ let mut output = Vec::new();
+
+ timing
+ .write_to(
+ &mut output,
+ std::time::Duration::from_millis(45),
+ 64,
+ 8,
+ 4,
+ 3,
+ 12,
+ true,
+ )
+ .unwrap();
+
+ assert_eq!(
+ String::from_utf8(output).unwrap(),
+ "[paimon-vindex] ivfpq_batch_timing nq=64 nprobe=8 pq_bits=4 \
+ topk=3 unique_lists=12 unique_list_rows=120 query_list_pairs=512 \
+ pq_codes_evaluated=240 sparse_query_list_pairs=128
dense_query_list_pairs=384 \
+ actual_pq_codes_evaluated=960 matched_rows=30 read_calls=10
requested_bytes=4096 \
+ queries_below_k=2 min_hits_per_query=1 filtered=true
total_ms=45.000 load_ms=1.000 \
+ preprocess_ms=2.000 coarse_ms=3.000 prepare_ms=4.000 \
+ io_read_ms=5.000 decode_ms=6.000 filter_ms=7.000 scan_ms=8.000 \
+ finalize_ms=9.000\n"
+ );
+ }
+
+ #[test]
+ fn ivfpq_batch_timing_distinguishes_sparse_and_dense_scan_work() {
+ let mut timing = IvfpqBatchTiming::default();
+ let sparse_rows = MatchingRows::Sparse((0..10).collect());
+ let dense_rows = MatchingRows::Sparse((0..20).collect());
+
+ timing.record_scan_work(100, Some(&sparse_rows), 4, 8, true);
+ timing.record_scan_work(100, Some(&dense_rows), 3, 8, true);
+
+ assert_eq!(timing.unique_list_rows, 200);
+ assert_eq!(timing.matched_rows, 30);
+ assert_eq!(timing.pq_codes_evaluated, 100);
+ assert_eq!(timing.sparse_query_list_pairs, 4);
+ assert_eq!(timing.dense_query_list_pairs, 3);
+ assert_eq!(timing.actual_pq_codes_evaluated, 340);
+ }
+
#[test]
fn matching_rows_adapts_sparse_positions_to_bounded_bitmap() {
let ids = (0..1024i64).collect::<Vec<_>>();
diff --git a/core/src/lib.rs b/core/src/lib.rs
index da0c188..6d9c64c 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -36,6 +36,7 @@ pub mod ivfrq_io;
pub mod ivfsq;
pub mod ivfsq_io;
pub mod kmeans;
+pub mod logging;
pub mod opq;
pub mod pq;
pub mod read_options;
diff --git a/core/src/logging.rs b/core/src/logging.rs
new file mode 100644
index 0000000..87f2934
--- /dev/null
+++ b/core/src/logging.rs
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Pluggable diagnostic log sink.
+//!
+//! By default runtime diagnostics are written to stderr, which keeps the
+//! historical behavior for the C FFI and Python consumers. Embedders (such as
+//! the JNI layer) may install a process-wide sink once to redirect records to
+//! their own logging system (e.g. SLF4J/log4j in Spark executors).
+
+use std::io::Write;
+use std::sync::OnceLock;
+
+/// Severity of a diagnostic record emitted by the core library.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum LogLevel {
+ Error = 1,
+ Warn = 2,
+ Info = 3,
+ Debug = 4,
+}
+
+/// Process-wide log sink. Receives the level and a message without a trailing
+/// newline. Implementations must never panic.
+pub type LogSink = Box<dyn Fn(LogLevel, &str) + Send + Sync>;
+
+static LOG_SINK: OnceLock<LogSink> = OnceLock::new();
+
+/// Installs the process-wide sink. The first caller wins; returns
+/// `Err(sink)` if a sink is already installed.
+pub fn set_log_sink(sink: LogSink) -> Result<(), LogSink> {
+ LOG_SINK.set(sink)
+}
+
+/// Emits one record through the installed sink, or falls back to stderr.
+pub(crate) fn emit_log(level: LogLevel, message: &str) {
+ match LOG_SINK.get() {
+ Some(sink) => sink(level, message),
+ None => {
+ let _ = writeln!(std::io::stderr().lock(), "{message}");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::{Arc, Mutex};
+
+ // The sink is a process-global OnceLock shared by every test in this
+ // binary, so install/deliver/reject must run inside one test.
+ #[test]
+ fn sink_install_deliver_and_reject_second_install() {
+ let captured: Arc<Mutex<Vec<(LogLevel, String)>>> =
Arc::new(Mutex::new(Vec::new()));
+ let sink_capture = Arc::clone(&captured);
+ set_log_sink(Box::new(move |level, message| {
+ sink_capture
+ .lock()
+ .unwrap()
+ .push((level, message.to_string()));
+ }))
+ .unwrap_or_else(|_| panic!("first install must succeed"));
+
+ emit_log(LogLevel::Info, "hello");
+ emit_log(LogLevel::Warn, "watch out");
+
+ let records = captured.lock().unwrap();
+ assert_eq!(
+ *records,
+ vec![
+ (LogLevel::Info, "hello".to_string()),
+ (LogLevel::Warn, "watch out".to_string()),
+ ]
+ );
+ drop(records);
+
+ assert!(set_log_sink(Box::new(|_, _| {})).is_err());
+ }
+}
diff --git a/core/src/sparse_table.rs b/core/src/sparse_table.rs
index 11072d8..6929d53 100644
--- a/core/src/sparse_table.rs
+++ b/core/src/sparse_table.rs
@@ -69,6 +69,12 @@ impl<V: Copy + Default> SparseTable<V> {
(self.keys[slot] == key).then(|| &self.values[slot])
}
+ pub(crate) fn get_mut(&mut self, key: u32) -> Option<&mut V> {
+ assert_ne!(key, EMPTY_KEY, "u32::MAX is reserved by SparseTable");
+ let slot = self.find_slot(key)?;
+ (self.keys[slot] == key).then(|| &mut self.values[slot])
+ }
+
pub(crate) fn insert(&mut self, key: u32, value: V) -> Option<V> {
assert_ne!(key, EMPTY_KEY, "u32::MAX is reserved by SparseTable");
let slot = self.find_slot(key).expect("SparseTable must have a slot");
@@ -171,6 +177,8 @@ mod tests {
assert_eq!(table.get(7), Some(&1));
assert_eq!(table.insert(7, 3), Some(1));
assert_eq!(table.get(7), Some(&3));
+ *table.get_mut(7).unwrap() = 5;
+ assert_eq!(table.get(7), Some(&5));
assert_eq!(table.len(), 1);
}
diff --git a/java/pom.xml b/java/pom.xml
index 6d0e1a4..0f48abf 100644
--- a/java/pom.xml
+++ b/java/pom.xml
@@ -67,8 +67,26 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Used by maven-remote-resources-plugin when generating
META-INF/NOTICE. -->
<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
+ <!-- Optional external JNI library path forwarded to forked test JVMs;
+ empty means load the platform library bundled in resources. -->
+ <paimon.vindex.native.path></paimon.vindex.native.path>
</properties>
+ <dependencies>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>1.7.36</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <version>1.7.36</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
<build>
<resources>
<resource>
@@ -109,6 +127,30 @@
<classpathScope>test</classpathScope>
</configuration>
</execution>
+ <!-- Forked JVM (exec goal): the Rust timing gate reads
the process
+ environment, which the in-process java goal cannot
set. -->
+ <execution>
+ <id>native-log-bridge-smoke-test</id>
+ <phase>test</phase>
+ <goals>
+ <goal>exec</goal>
+ </goals>
+ <configuration>
+ <skip>${skipTests}</skip>
+ <executable>java</executable>
+ <classpathScope>test</classpathScope>
+ <environmentVariables>
+
<PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING>1</PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING>
+ </environmentVariables>
+ <arguments>
+
<argument>-Dvindex.smoke.require-timing=true</argument>
+
<argument>-Dpaimon.vindex.native.path=${paimon.vindex.native.path}</argument>
+ <argument>-classpath</argument>
+ <classpath/>
+
<argument>org.apache.paimon.index.vector.NativeLogBridgeSmokeTest</argument>
+ </arguments>
+ </configuration>
+ </execution>
</executions>
</plugin>
</plugins>
diff --git
a/java/src/main/java/org/apache/paimon/index/vector/NativeLogBridge.java
b/java/src/main/java/org/apache/paimon/index/vector/NativeLogBridge.java
new file mode 100644
index 0000000..fdf0fbe
--- /dev/null
+++ b/java/src/main/java/org/apache/paimon/index/vector/NativeLogBridge.java
@@ -0,0 +1,62 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.paimon.index.vector;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Receives diagnostic records from the Rust core (installed by JNI_OnLoad in
+ * jni/src/log_bridge.rs) and forwards them to SLF4J. Package-private is
+ * sufficient: JNI method lookup does not enforce Java access control.
+ */
+final class NativeLogBridge {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(NativeLogBridge.class);
+
+ private NativeLogBridge() {}
+
+ // Called from native code; the signature must remain
(ILjava/lang/String;)V.
+ // Must never throw: a pending exception here would surface on arbitrary
+ // native threads.
+ static void log(int level, String message) {
+ try {
+ switch (level) {
+ case 1:
+ LOG.error(message);
+ break;
+ case 2:
+ LOG.warn(message);
+ break;
+ case 4:
+ LOG.debug(message);
+ break;
+ case 3:
+ default:
+ LOG.info(message);
+ break;
+ }
+ } catch (Throwable ignored) {
+ try {
+ System.err.println(message);
+ } catch (Throwable ignoredFallback) {
+ // Keep the JNI callback non-throwing even if stderr itself
fails.
+ }
+ }
+ }
+}
diff --git
a/java/src/test/java/org/apache/paimon/index/vector/NativeLogBridgeSmokeTest.java
b/java/src/test/java/org/apache/paimon/index/vector/NativeLogBridgeSmokeTest.java
new file mode 100644
index 0000000..c774eae
--- /dev/null
+++
b/java/src/test/java/org/apache/paimon/index/vector/NativeLogBridgeSmokeTest.java
@@ -0,0 +1,202 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.paimon.index.vector;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * Standalone check that native IVF-PQ timing diagnostics reach SLF4J (via
+ * NativeLogBridge) instead of the raw process stderr.
+ *
+ * <p>Requires the environment variable PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING
to be set
+ * before the JVM starts (the Rust gate reads the process environment); prints
a
+ * skip notice and exits 0 otherwise. Run with slf4j-simple on the classpath:
+ *
+ * <pre>
+ * PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING=1 java -cp ... \
+ * org.apache.paimon.index.vector.NativeLogBridgeSmokeTest
[/path/to/libpaimon_vindex_jni.so]
+ * </pre>
+ */
+public class NativeLogBridgeSmokeTest {
+
+ private static final String TIMING_MARKER = "ivfpq_batch_timing";
+ private static final String SLF4J_TIMING_PREFIX =
+ "INFO "
+ + NativeLogBridge.class.getName()
+ + " - [paimon-vindex] "
+ + TIMING_MARKER;
+ private static final String[] REQUIRED_TIMING_FIELDS = {
+ "topk",
+ "unique_list_rows",
+ "query_list_pairs",
+ "pq_codes_evaluated",
+ "matched_rows",
+ "read_calls",
+ "requested_bytes",
+ "queries_below_k",
+ "min_hits_per_query",
+ "io_read_ms",
+ "decode_ms"
+ };
+
+ public static void main(String[] args) {
+ if (System.getenv("PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING") == null) {
+ if (Boolean.getBoolean("vindex.smoke.require-timing")) {
+ throw new AssertionError(
+ "PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING must be set in
the process environment "
+ + "when vindex.smoke.require-timing=true");
+ }
+ System.out.println(
+ "SKIP: PAIMON_VINDEX_LOG_IVFPQ_BATCH_TIMING is not set in
the process environment");
+ return;
+ }
+ VectorIndexNativeLoaderSmokeTest.configureExternalLibrary(args);
+ if (!hasNativeLibrary()) {
+ System.out.println("SKIP: no explicit or bundled JNI library is
available");
+ return;
+ }
+
+ PrintStream originalOut = System.out;
+ PrintStream originalErr = System.err;
+ ByteArrayOutputStream capturedOut = new ByteArrayOutputStream();
+ ByteArrayOutputStream capturedErr = new ByteArrayOutputStream();
+ String out;
+ String err;
+ try {
+ // Capture before the first native/SLF4J use: slf4j-simple logs to
+ // System.err by default and does not cache the stream.
+ System.setOut(new PrintStream(capturedOut, true));
+ System.setErr(new PrintStream(capturedErr, true));
+ runIvfPqBatchSearch();
+ } finally {
+ System.setOut(originalOut);
+ System.setErr(originalErr);
+ out = capturedOut.toString();
+ err = capturedErr.toString();
+ }
+
+ if (out.contains(TIMING_MARKER)) {
+ throw new AssertionError(
+ "timing record leaked to stdout instead of the log
bridge:\n" + out);
+ }
+ if (!err.contains(SLF4J_TIMING_PREFIX)) {
+ throw new AssertionError(
+ "timing record missing from SLF4J output.\nstderr:\n"
+ + err
+ + "\nstdout:\n"
+ + out);
+ }
+ for (String field : REQUIRED_TIMING_FIELDS) {
+ if (!err.contains(field + "=")) {
+ throw new AssertionError("timing field " + field + "
missing:\n" + err);
+ }
+ }
+ if (err.contains("read_decode_ms=")) {
+ throw new AssertionError("combined read/decode timing was not
split:\n" + err);
+ }
+ if (err.contains("native log bridge disabled")) {
+ throw new AssertionError("bridge unexpectedly degraded to
stderr:\n" + err);
+ }
+ System.out.println("OK: " + TIMING_MARKER + " delivered via
NativeLogBridge/SLF4J only");
+ }
+
+ private static boolean hasNativeLibrary() {
+ String explicitPath =
System.getProperty(NativeLibraryLoader.NATIVE_PATH_PROPERTY);
+ if (explicitPath != null && !explicitPath.trim().isEmpty()) {
+ return true;
+ }
+ try {
+ String resourcePath =
+ NativeLibraryLoader.resourcePath(
+ System.getProperty("os.name"),
System.getProperty("os.arch"));
+ return NativeLogBridgeSmokeTest.class.getResource(resourcePath) !=
null;
+ } catch (UnsatisfiedLinkError ignored) {
+ return false;
+ }
+ }
+
+ private static void runIvfPqBatchSearch() {
+ int dimension = 8;
+ int vectorCount = 512;
+ float[] data = new float[vectorCount * dimension];
+ long[] ids = new long[vectorCount];
+ Random random = new Random(13L);
+ for (int row = 0; row < vectorCount; row++) {
+ ids[row] = row;
+ for (int column = 0; column < dimension; column++) {
+ data[row * dimension + column] = (float) random.nextGaussian();
+ }
+ }
+
+ Map<String, String> options = new HashMap<String, String>();
+ options.put("index.type", "ivf_pq");
+ options.put("dimension", Integer.toString(dimension));
+ options.put("nlist", "2");
+ options.put("metric", "l2");
+ options.put("use-opq", "false");
+
+ VectorIndexWriter writer =
+ new VectorIndexWriter(VectorIndexTrainer.train(options, data,
vectorCount));
+ byte[] indexBytes;
+ try {
+ writer.addVectors(ids, data, vectorCount);
+ VectorIndexNativeHandleSafetyTest.ByteArrayPositionOutputStream
output =
+ new
VectorIndexNativeHandleSafetyTest.ByteArrayPositionOutputStream();
+ writer.writeIndex(output);
+ indexBytes = output.toByteArray();
+ } finally {
+ writer.close();
+ }
+
+ VectorIndexReader reader = new VectorIndexReader(new
ByteArrayInput(indexBytes));
+ try {
+ int queryCount = 4;
+ int topK = 3;
+ float[] queries = new float[queryCount * dimension];
+ for (int offset = 0; offset < queries.length; offset++) {
+ queries[offset] = (float) random.nextGaussian();
+ }
+ VectorSearchBatchResult result =
+ reader.searchBatch(queries, queryCount, new
VectorSearchParams(topK, 2));
+ if (result.ids().length != queryCount * topK) {
+ throw new AssertionError("unexpected batch result size: " +
result.ids().length);
+ }
+ } finally {
+ reader.close();
+ }
+ }
+
+ private static final class ByteArrayInput implements VectorIndexInput {
+ private final byte[] data;
+
+ ByteArrayInput(byte[] data) {
+ this.data = data;
+ }
+
+ @Override
+ public void pread(long[] positions, byte[][] buffers) {
+ for (int i = 0; i < positions.length; i++) {
+ System.arraycopy(data, (int) positions[i], buffers[i], 0,
buffers[i].length);
+ }
+ }
+ }
+}
diff --git a/jni/src/lib.rs b/jni/src/lib.rs
index db6eb38..e025cbd 100644
--- a/jni/src/lib.rs
+++ b/jni/src/lib.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+mod log_bridge;
mod stream;
use jni::objects::{JByteArray, JClass, JFloatArray, JLongArray, JObject,
JValue};
diff --git a/jni/src/log_bridge.rs b/jni/src/log_bridge.rs
new file mode 100644
index 0000000..79fd786
--- /dev/null
+++ b/jni/src/log_bridge.rs
@@ -0,0 +1,130 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Installs a core log sink that forwards diagnostic records to
+//! `org.apache.paimon.index.vector.NativeLogBridge`, so native output reaches
+//! SLF4J/log4j instead of the raw process stderr (which Spark's
+//! System.out-to-log4j redirect cannot capture).
+
+use jni::objects::{GlobalRef, JStaticMethodID, JValue};
+use jni::signature::{Primitive, ReturnType};
+use jni::sys::{jint, JNI_ERR, JNI_VERSION_1_8};
+use jni::{JNIEnv, JavaVM};
+use paimon_vindex_core::logging::{set_log_sink, LogLevel};
+use std::ffi::c_void;
+use std::panic::{catch_unwind, AssertUnwindSafe};
+
+const BRIDGE_CLASS: &str = "org/apache/paimon/index/vector/NativeLogBridge";
+
+struct LogBridge {
+ vm: JavaVM,
+ // Pins NativeLogBridge (and its classloader) so the method id stays valid.
+ class: GlobalRef,
+ log_method: JStaticMethodID,
+}
+
+/// Called synchronously by `System.load` (`NativeLibraryLoader.load`). Never
+/// leaves a pending exception and never fails the library load just because
+/// logging is unavailable (e.g. slf4j-api absent outside Spark).
+#[no_mangle]
+pub extern "system" fn JNI_OnLoad(vm: *mut jni::sys::JavaVM, _reserved: *mut
c_void) -> jint {
+ let vm = match unsafe { JavaVM::from_raw(vm) } {
+ Ok(vm) => vm,
+ Err(_) => return JNI_ERR,
+ };
+ if let Err(error) = install_log_bridge(&vm) {
+ eprintln!("[paimon-vindex] native log bridge disabled, keeping stderr
logging: {error}");
+ }
+ JNI_VERSION_1_8
+}
+
+fn install_log_bridge(vm: &JavaVM) -> Result<(), jni::errors::Error> {
+ // The JNI_OnLoad thread is already attached; FindClass here resolves
+ // through NativeLibraryLoader's classloader (JNI spec), which also loads
+ // NativeLogBridge.
+ let mut env = vm.get_env()?;
+ let found = env.find_class(BRIDGE_CLASS);
+ let class = clear_on_err(&mut env, found)?;
+ let method = env.get_static_method_id(&class, "log",
"(ILjava/lang/String;)V");
+ let log_method = clear_on_err(&mut env, method)?;
+ let class = env.new_global_ref(&class)?;
+ let bridge = LogBridge {
+ vm: unsafe { JavaVM::from_raw(vm.get_java_vm_pointer())? },
+ class,
+ log_method,
+ };
+ let _ = set_log_sink(Box::new(move |level, message| {
+ // Contract: never panic, never leave a pending exception behind.
+ let delivered =
+ catch_unwind(AssertUnwindSafe(|| forward(&bridge, level,
message))).unwrap_or(false);
+ if !delivered {
+ eprintln!("{message}");
+ }
+ }));
+ Ok(())
+}
+
+fn clear_on_err<T>(env: &mut JNIEnv, result: jni::errors::Result<T>) ->
jni::errors::Result<T> {
+ // A failed FindClass/GetStaticMethodID leaves a pending exception which
+ // would make System.load throw; clear it before degrading gracefully.
+ if result.is_err() && env.exception_check().unwrap_or(false) {
+ let _ = env.exception_clear();
+ }
+ result
+}
+
+fn forward(bridge: &LogBridge, level: LogLevel, message: &str) -> bool {
+ // No-op for already-attached threads; daemon-attaches Rayon workers so
+ // they never block DestroyJavaVM. Auto-detach happens at thread exit.
+ let Ok(mut env) = bridge.vm.attach_current_thread_as_daemon() else {
+ return false;
+ };
+ // Daemon-attached worker threads have no Java frame to release locals.
+ env.with_local_frame(4, |env| -> jni::errors::Result<bool> {
+ let jmsg = env.new_string(message)?;
+ let args = [
+ JValue::Int(level as jint).as_jni(),
+ JValue::Object(&jmsg).as_jni(),
+ ];
+ let ok = unsafe {
+ env.call_static_method_unchecked(
+ &bridge.class,
+ bridge.log_method,
+ ReturnType::Primitive(Primitive::Void),
+ &args,
+ )
+ }
+ .is_ok();
+ if !ok || env.exception_check()? {
+ let _ = env.exception_clear();
+ return Ok(false);
+ }
+ Ok(true)
+ })
+ .unwrap_or(false)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn log_bridge_is_send_and_sync() {
+ fn assert_send_sync<T: Send + Sync>() {}
+ assert_send_sync::<LogBridge>();
+ }
+}