Skip to main content

risingwave_storage/hummock/compactor/
shared_buffer_compact.rs

1// Copyright 2022 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
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
16use std::ops::Bound;
17use std::sync::atomic::AtomicUsize;
18use std::sync::atomic::Ordering::Relaxed;
19use std::sync::{Arc, LazyLock};
20
21use await_tree::InstrumentAwait;
22use bytes::Bytes;
23use foyer::Hint;
24use futures::future::try_join;
25use futures::{FutureExt, StreamExt, stream};
26use itertools::Itertools;
27use risingwave_common::catalog::TableId;
28use risingwave_hummock_sdk::key::{EPOCH_LEN, FullKey};
29use risingwave_hummock_sdk::key_range::KeyRange;
30use risingwave_hummock_sdk::{EpochWithGap, KeyComparator, LocalSstableInfo};
31use risingwave_pb::hummock::{PbSstableFilterLayout, PbSstableFilterType};
32use thiserror_ext::AsReport;
33use tracing::error;
34
35use crate::compaction_catalog_manager::{CompactionCatalogAgentRef, CompactionCatalogManagerRef};
36use crate::hummock::compactor::compaction_filter::DummyCompactionFilter;
37use crate::hummock::compactor::compaction_utils::{
38    blocked_xor_filter_key_count_threshold, estimate_output_key_count_by_size,
39};
40use crate::hummock::compactor::context::{CompactorContext, await_tree_key};
41use crate::hummock::compactor::{CompactOutput, Compactor, check_flush_result};
42use crate::hummock::event_handler::uploader::UploadTaskOutput;
43use crate::hummock::iterator::{Forward, HummockIterator, MergeIterator, UserIterator};
44use crate::hummock::{
45    CachePolicy, GetObjectId, HummockError, HummockResult, ObjectIdManagerRef,
46    SstableBuilderOptions,
47};
48use crate::mem_table::ImmutableMemtable;
49use crate::opts::StorageOpts;
50
51const GC_DELETE_KEYS_FOR_FLUSH: bool = false;
52
53/// Flush shared buffer to level0. Resulted SSTs are grouped by compaction group.
54pub async fn compact(
55    context: CompactorContext,
56    object_id_manager: ObjectIdManagerRef,
57    payload: Vec<ImmutableMemtable>,
58    compaction_catalog_manager_ref: CompactionCatalogManagerRef,
59) -> HummockResult<UploadTaskOutput> {
60    let table_ids_with_old_value: HashSet<TableId> = payload
61        .iter()
62        .filter(|imm| imm.has_old_value())
63        .map(|imm| imm.table_id)
64        .collect();
65    let mut non_log_store_new_value_payload = Vec::with_capacity(payload.len());
66    let mut log_store_new_value_payload = Vec::with_capacity(payload.len());
67    let mut old_value_payload = Vec::with_capacity(payload.len());
68    for imm in payload {
69        if table_ids_with_old_value.contains(&imm.table_id) {
70            if imm.has_old_value() {
71                old_value_payload.push(imm.clone());
72            }
73            log_store_new_value_payload.push(imm);
74        } else {
75            assert!(!imm.has_old_value());
76            non_log_store_new_value_payload.push(imm);
77        }
78    }
79    let non_log_store_new_value_future = async {
80        if non_log_store_new_value_payload.is_empty() {
81            Ok(vec![])
82        } else {
83            compact_shared_buffer::<true>(
84                context.clone(),
85                object_id_manager.clone(),
86                compaction_catalog_manager_ref.clone(),
87                non_log_store_new_value_payload,
88            )
89            .instrument_await("shared_buffer_compact_non_log_store_new_value")
90            .await
91        }
92    };
93
94    let log_store_new_value_future = async {
95        if log_store_new_value_payload.is_empty() {
96            Ok(vec![])
97        } else {
98            compact_shared_buffer::<true>(
99                context.clone(),
100                object_id_manager.clone(),
101                compaction_catalog_manager_ref.clone(),
102                log_store_new_value_payload,
103            )
104            .instrument_await("shared_buffer_compact_log_store_new_value")
105            .await
106        }
107    };
108
109    let old_value_future = async {
110        if old_value_payload.is_empty() {
111            Ok(vec![])
112        } else {
113            compact_shared_buffer::<false>(
114                context.clone(),
115                object_id_manager.clone(),
116                compaction_catalog_manager_ref.clone(),
117                old_value_payload,
118            )
119            .instrument_await("shared_buffer_compact_log_store_old_value")
120            .await
121        }
122    };
123
124    // Note that the output is reordered compared with input `payload`.
125    let ((non_log_store_new_value_ssts, log_store_new_value_ssts), old_value_ssts) = try_join(
126        try_join(non_log_store_new_value_future, log_store_new_value_future),
127        old_value_future,
128    )
129    .await?;
130
131    let mut new_value_ssts = non_log_store_new_value_ssts;
132    new_value_ssts.extend(log_store_new_value_ssts);
133
134    Ok(UploadTaskOutput {
135        new_value_ssts,
136        old_value_ssts,
137        wait_poll_timer: None,
138    })
139}
140
141/// For compaction from shared buffer to level 0, this is the only function gets called.
142///
143/// The `IS_NEW_VALUE` flag means for the given payload, we are doing compaction using its new value or old value.
144/// When `IS_NEW_VALUE` is false, we are compacting with old value, and the payload imms should have `old_values` not `None`
145async fn compact_shared_buffer<const IS_NEW_VALUE: bool>(
146    context: CompactorContext,
147    object_id_manager: ObjectIdManagerRef,
148    compaction_catalog_manager_ref: CompactionCatalogManagerRef,
149    mut payload: Vec<ImmutableMemtable>,
150) -> HummockResult<Vec<LocalSstableInfo>> {
151    if !IS_NEW_VALUE {
152        assert!(payload.iter().all(|imm| imm.has_old_value()));
153    }
154    // Local memory compaction looks at all key ranges.
155    let existing_table_ids: HashSet<TableId> =
156        payload.iter().map(|imm| imm.table_id).dedup().collect();
157    assert!(!existing_table_ids.is_empty());
158
159    let compaction_catalog_agent_ref = compaction_catalog_manager_ref
160        .acquire(existing_table_ids.iter().copied().collect())
161        .await?;
162    let existing_table_ids = compaction_catalog_agent_ref
163        .table_ids()
164        .collect::<HashSet<_>>();
165    payload.retain(|imm| {
166        let ret = existing_table_ids.contains(&imm.table_id);
167        if !ret {
168            error!(
169                "cannot find table {:?}; it may have been removed by the meta service",
170                imm.table_id
171            );
172        }
173        ret
174    });
175
176    let total_key_count = payload.iter().map(|imm| imm.key_count()).sum::<usize>();
177    let (splits, sub_compaction_sstable_size, table_vnode_partition, compact_data_size) =
178        generate_splits(&payload, &existing_table_ids, context.storage_opts.as_ref());
179    let parallelism = splits.len();
180    let mut compact_success = true;
181    let mut output_ssts = Vec::with_capacity(parallelism);
182    let mut compaction_futures = vec![];
183    // Shared buffer compaction always goes to L0. Use a blocked filter when kv_count is large.
184    // Use None to apply the default threshold since shared buffer flush doesn't have a CompactTask.
185    let estimated_output_key_count = estimate_output_key_count_by_size(
186        total_key_count as u64,
187        compact_data_size,
188        sub_compaction_sstable_size as usize,
189    );
190    let sstable_filter_layout =
191        if risingwave_hummock_sdk::filter_utils::should_use_blocked_xor_filter_by_kv_count(
192            estimated_output_key_count as u64,
193            None,
194        ) {
195            PbSstableFilterLayout::Blocked
196        } else {
197            PbSstableFilterLayout::Plain
198        };
199
200    for (split_index, key_range) in splits.into_iter().enumerate() {
201        let compactor = SharedBufferCompactRunner::new(
202            split_index,
203            key_range,
204            context.clone(),
205            sub_compaction_sstable_size as usize,
206            estimated_output_key_count,
207            table_vnode_partition.clone(),
208            sstable_filter_layout,
209            object_id_manager.clone(),
210        );
211        let mut forward_iters = Vec::with_capacity(payload.len());
212        for imm in &payload {
213            forward_iters.push(imm.clone().into_directed_iter::<Forward, IS_NEW_VALUE>());
214        }
215        let compaction_executor = context.compaction_executor.clone();
216        let compaction_catalog_agent_ref = compaction_catalog_agent_ref.clone();
217        let handle = compaction_executor.spawn({
218            static NEXT_SHARED_BUFFER_COMPACT_ID: LazyLock<AtomicUsize> =
219                LazyLock::new(|| AtomicUsize::new(0));
220            let tree_root = context.await_tree_reg.as_ref().map(|reg| {
221                let id = NEXT_SHARED_BUFFER_COMPACT_ID.fetch_add(1, Relaxed);
222                reg.register(
223                    await_tree_key::CompactSharedBuffer { id },
224                    format!(
225                        "Compact Shared Buffer: {:?}",
226                        payload
227                            .iter()
228                            .map(|imm| imm.epoch())
229                            .collect::<BTreeSet<_>>()
230                    ),
231                )
232            });
233            let future = compactor.run(
234                MergeIterator::new(forward_iters),
235                compaction_catalog_agent_ref,
236            );
237            if let Some(root) = tree_root {
238                root.instrument(future).left_future()
239            } else {
240                future.right_future()
241            }
242        });
243        compaction_futures.push(handle);
244    }
245
246    let mut buffered = stream::iter(compaction_futures).buffer_unordered(parallelism);
247    let mut err = None;
248    while let Some(future_result) = buffered.next().await {
249        match future_result {
250            Ok(Ok((split_index, ssts, table_stats_map))) => {
251                output_ssts.push((split_index, ssts, table_stats_map));
252            }
253            Ok(Err(e)) => {
254                compact_success = false;
255                tracing::warn!(error = %e.as_report(), "Shared Buffer Compaction failed with error");
256                err = Some(e);
257            }
258            Err(e) => {
259                compact_success = false;
260                tracing::warn!(
261                    error = %e.as_report(),
262                    "Shared Buffer Compaction failed with future error",
263                );
264                err = Some(HummockError::compaction_executor(
265                    "failed while execute in tokio",
266                ));
267            }
268        }
269    }
270
271    // Sort by split/key range index.
272    output_ssts.sort_by_key(|(split_index, ..)| *split_index);
273
274    if compact_success {
275        let mut level0 = Vec::with_capacity(parallelism);
276        let mut sst_infos = vec![];
277        for (_, ssts, _) in output_ssts {
278            for sst_info in &ssts {
279                context
280                    .compactor_metrics
281                    .write_build_l0_bytes
282                    .inc_by(sst_info.file_size());
283
284                sst_infos.push(sst_info.sst_info.clone());
285            }
286            level0.extend(ssts);
287        }
288        if context.storage_opts.check_compaction_result {
289            let compaction_executor = context.compaction_executor.clone();
290            let mut forward_iters = Vec::with_capacity(payload.len());
291            for imm in &payload {
292                if !existing_table_ids.contains(&imm.table_id) {
293                    continue;
294                }
295                forward_iters.push(imm.clone().into_forward_iter());
296            }
297            let iter = MergeIterator::new(forward_iters);
298            let left_iter = UserIterator::new(
299                iter,
300                (Bound::Unbounded, Bound::Unbounded),
301                u64::MAX,
302                0,
303                None,
304            );
305            compaction_executor.spawn(async move {
306                match check_flush_result(left_iter, sst_infos, context).await {
307                    Err(e) => {
308                        tracing::warn!(
309                            error = %e.as_report(),
310                            "failed to check the memtable flush result",
311                        );
312                    }
313                    Ok(true) => (),
314                    Ok(false) => {
315                        panic!(
316                            "failed to check flush result consistency of state-table {:?}",
317                            existing_table_ids
318                        );
319                    }
320                }
321            });
322        }
323        Ok(level0)
324    } else {
325        Err(err.unwrap())
326    }
327}
328
329///  Based on the incoming payload and opts, calculate the sharding method and sstable size of shared buffer compaction.
330fn generate_splits(
331    payload: &Vec<ImmutableMemtable>,
332    existing_table_ids: &HashSet<TableId>,
333    storage_opts: &StorageOpts,
334) -> (Vec<KeyRange>, u64, BTreeMap<TableId, u32>, u64) {
335    let mut size_and_start_user_keys = vec![];
336    let mut compact_data_size = 0;
337    let mut table_size_infos: HashMap<TableId, u64> = HashMap::default();
338    let mut table_vnode_partition = BTreeMap::default();
339    for imm in payload {
340        let data_size = {
341            // calculate encoded bytes of key var length
342            (imm.value_count() * EPOCH_LEN + imm.size()) as u64
343        };
344        compact_data_size += data_size;
345        size_and_start_user_keys.push((
346            data_size,
347            FullKey {
348                user_key: imm.start_user_key(),
349                epoch_with_gap: EpochWithGap::new_max_epoch(),
350            }
351            .encode(),
352        ));
353        let v = table_size_infos.entry(imm.table_id).or_insert(0);
354        *v += data_size;
355    }
356
357    size_and_start_user_keys
358        .sort_by(|a, b| KeyComparator::compare_encoded_full_key(a.1.as_ref(), b.1.as_ref()));
359    let mut splits = Vec::with_capacity(size_and_start_user_keys.len());
360    splits.push(KeyRange::new(Bytes::new(), Bytes::new()));
361    let sstable_size = (storage_opts.sstable_size_mb as u64) << 20;
362    let min_sstable_size = (storage_opts.min_sstable_size_mb as u64) << 20;
363    let parallel_compact_size = (storage_opts.parallel_compact_size_mb as u64) << 20;
364    let parallelism = std::cmp::min(
365        storage_opts.share_buffers_sync_parallelism as u64,
366        size_and_start_user_keys.len() as u64,
367    );
368    let sub_compaction_data_size = if compact_data_size > parallel_compact_size && parallelism > 1 {
369        compact_data_size / parallelism
370    } else {
371        compact_data_size
372    };
373
374    if parallelism > 1 && compact_data_size > sstable_size {
375        let mut last_buffer_size = 0;
376        let mut last_key: Vec<u8> = vec![];
377        for (data_size, key) in size_and_start_user_keys {
378            if last_buffer_size >= sub_compaction_data_size && !last_key.eq(&key) {
379                splits.last_mut().unwrap().right = Bytes::from(key.clone());
380                splits.push(KeyRange::new(Bytes::from(key.clone()), Bytes::default()));
381                last_buffer_size = data_size;
382            } else {
383                last_buffer_size += data_size;
384            }
385
386            last_key = key;
387        }
388    }
389
390    if compact_data_size > sstable_size {
391        // Meta node will calculate size of each state-table in one task in `risingwave_meta::hummock::manager::compaction::calculate_vnode_partition`.
392        // To make the calculate result more accurately we shall split the large state-table from other small ones.
393        for table_id in existing_table_ids {
394            if let Some(table_size) = table_size_infos.get(table_id)
395                && *table_size > min_sstable_size
396            {
397                table_vnode_partition.insert(*table_id, 1);
398            }
399        }
400    }
401
402    // mul 1.2 for other extra memory usage.
403    // Ensure that the size of each sstable is still less than `sstable_size` after optimization to avoid generating a huge size sstable which will affect the object store
404    let sub_compaction_sstable_size = std::cmp::min(sstable_size, sub_compaction_data_size * 6 / 5);
405    (
406        splits,
407        sub_compaction_sstable_size,
408        table_vnode_partition,
409        compact_data_size,
410    )
411}
412
413pub struct SharedBufferCompactRunner {
414    compactor: Compactor,
415    split_index: usize,
416}
417
418impl SharedBufferCompactRunner {
419    pub fn new(
420        split_index: usize,
421        key_range: KeyRange,
422        context: CompactorContext,
423        sub_compaction_sstable_size: usize,
424        estimated_output_key_count: usize,
425        table_vnode_partition: BTreeMap<TableId, u32>,
426        sstable_filter_layout: PbSstableFilterLayout,
427        object_id_getter: Arc<dyn GetObjectId>,
428    ) -> Self {
429        let mut options: SstableBuilderOptions = context.storage_opts.as_ref().into();
430        options.capacity = sub_compaction_sstable_size;
431        options.estimated_output_key_count = Some(estimated_output_key_count);
432        options.filter_hash_prealloc_key_count_cap = blocked_xor_filter_key_count_threshold(None);
433        let compactor = Compactor::new(
434            context,
435            options,
436            super::TaskConfig {
437                key_range,
438                cache_policy: CachePolicy::Fill(Hint::Normal),
439                gc_delete_keys: GC_DELETE_KEYS_FOR_FLUSH,
440                retain_multiple_version: true,
441                table_vnode_partition,
442                sstable_filter_layout,
443                // L0 flush writes overlapping SSTs, so keep a conservative filter type here.
444                sstable_filter_type: PbSstableFilterType::SstableFilterXor16,
445                table_schemas: Default::default(),
446                disable_drop_column_optimization: false,
447            },
448            object_id_getter,
449        );
450        Self {
451            compactor,
452            split_index,
453        }
454    }
455
456    pub async fn run(
457        self,
458        iter: impl HummockIterator<Direction = Forward>,
459        compaction_catalog_agent_ref: CompactionCatalogAgentRef,
460    ) -> HummockResult<CompactOutput> {
461        let dummy_compaction_filter = DummyCompactionFilter {};
462        let (ssts, table_stats_map) = self
463            .compactor
464            .compact_key_range(
465                iter,
466                dummy_compaction_filter,
467                compaction_catalog_agent_ref,
468                None,
469                None,
470                None,
471            )
472            .await?;
473        Ok((self.split_index, ssts, table_stats_map))
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use std::collections::HashSet;
480
481    use bytes::Bytes;
482    use risingwave_common::catalog::TableId;
483    use risingwave_common::hash::VirtualNode;
484    use risingwave_common::util::epoch::test_epoch;
485    use risingwave_hummock_sdk::key::{TableKey, prefix_slice_with_vnode};
486
487    use crate::hummock::compactor::shared_buffer_compact::generate_splits;
488    use crate::hummock::shared_buffer::shared_buffer_batch::SharedBufferValue;
489    use crate::mem_table::ImmutableMemtable;
490    use crate::opts::StorageOpts;
491
492    fn generate_key(key: &str) -> TableKey<Bytes> {
493        TableKey(prefix_slice_with_vnode(
494            VirtualNode::from_index(1),
495            key.as_bytes(),
496        ))
497    }
498
499    #[tokio::test]
500    async fn test_generate_splits_in_order() {
501        let imm1 = ImmutableMemtable::build_shared_buffer_batch_for_test(
502            test_epoch(3),
503            0,
504            vec![(
505                generate_key("dddd"),
506                SharedBufferValue::Insert(Bytes::from_static(b"v3")),
507            )],
508            1024 * 1024,
509            TableId::new(1),
510        );
511        let imm2 = ImmutableMemtable::build_shared_buffer_batch_for_test(
512            test_epoch(3),
513            0,
514            vec![(
515                generate_key("abb"),
516                SharedBufferValue::Insert(Bytes::from_static(b"v3")),
517            )],
518            (1024 + 256) * 1024,
519            TableId::new(1),
520        );
521
522        let imm3 = ImmutableMemtable::build_shared_buffer_batch_for_test(
523            test_epoch(2),
524            0,
525            vec![(
526                generate_key("abc"),
527                SharedBufferValue::Insert(Bytes::from_static(b"v2")),
528            )],
529            (1024 + 512) * 1024,
530            TableId::new(1),
531        );
532        let imm4 = ImmutableMemtable::build_shared_buffer_batch_for_test(
533            test_epoch(3),
534            0,
535            vec![(
536                generate_key("aaa"),
537                SharedBufferValue::Insert(Bytes::from_static(b"v3")),
538            )],
539            (1024 + 512) * 1024,
540            TableId::new(1),
541        );
542
543        let imm5 = ImmutableMemtable::build_shared_buffer_batch_for_test(
544            test_epoch(3),
545            0,
546            vec![(
547                generate_key("aaa"),
548                SharedBufferValue::Insert(Bytes::from_static(b"v3")),
549            )],
550            (1024 + 256) * 1024,
551            TableId::new(2),
552        );
553
554        let storage_opts = StorageOpts {
555            share_buffers_sync_parallelism: 3,
556            parallel_compact_size_mb: 1,
557            sstable_size_mb: 1,
558            ..Default::default()
559        };
560        let payload = vec![imm1, imm2, imm3, imm4, imm5];
561        let (splits, _sstable_capacity, vnodes, _) = generate_splits(
562            &payload,
563            &HashSet::from_iter([1.into(), 2.into()]),
564            &storage_opts,
565        );
566        assert_eq!(
567            splits.len(),
568            storage_opts.share_buffers_sync_parallelism as usize
569        );
570        assert!(vnodes.is_empty());
571
572        // Basic validation: splits should be continuous and monotonic
573        for i in 1..splits.len() {
574            assert_eq!(splits[i].left, splits[i - 1].right);
575            assert!(splits[i].left > splits[i - 1].left);
576            assert!(splits[i].right.is_empty() || splits[i].left < splits[i].right);
577        }
578    }
579
580    #[tokio::test]
581    async fn test_generate_splits_no_duplicate_keys() {
582        // Create test data with specific ordering to detect sorting issues
583        // Make data sizes large enough to trigger splitting
584        let imm1 = ImmutableMemtable::build_shared_buffer_batch_for_test(
585            test_epoch(1),
586            0,
587            vec![(
588                generate_key("zzz"), // This should be last after sorting
589                SharedBufferValue::Insert(Bytes::from_static(b"v1")),
590            )],
591            2 * 1024 * 1024, // 2MB to ensure compact_data_size > sstable_size
592            TableId::new(1),
593        );
594
595        let imm2 = ImmutableMemtable::build_shared_buffer_batch_for_test(
596            test_epoch(1),
597            0,
598            vec![(
599                generate_key("aaa"), // This should be first after sorting
600                SharedBufferValue::Insert(Bytes::from_static(b"v1")),
601            )],
602            2 * 1024 * 1024, // 2MB
603            TableId::new(1),
604        );
605
606        let imm3 = ImmutableMemtable::build_shared_buffer_batch_for_test(
607            test_epoch(1),
608            0,
609            vec![(
610                generate_key("mmm"), // This should be middle after sorting
611                SharedBufferValue::Insert(Bytes::from_static(b"v1")),
612            )],
613            2 * 1024 * 1024, // 2MB
614            TableId::new(1),
615        );
616
617        let storage_opts = StorageOpts {
618            share_buffers_sync_parallelism: 3, // Enable parallelism
619            parallel_compact_size_mb: 2,       // Small threshold to trigger splitting
620            sstable_size_mb: 1,                // Small SSTable size to trigger condition
621            ..Default::default()
622        };
623
624        // Test with payload in wrong order (zzz, aaa, mmm) instead of sorted order (aaa, mmm, zzz)
625        let payload = vec![imm1, imm2, imm3];
626        let (splits, _sstable_capacity, _vnodes, _) =
627            generate_splits(&payload, &HashSet::from_iter([1.into()]), &storage_opts);
628
629        // Should have multiple splits due to large data size
630        assert!(
631            splits.len() > 1,
632            "Expected multiple splits, got {}",
633            splits.len()
634        );
635
636        // Verify no key range overlaps between splits
637        for i in 0..splits.len() {
638            for j in (i + 1)..splits.len() {
639                let split_i = &splits[i];
640                let split_j = &splits[j];
641
642                // Check that splits don't overlap
643                if !split_i.right.is_empty() && !split_j.left.is_empty() {
644                    assert!(
645                        split_i.right <= split_j.left || split_j.right <= split_i.left,
646                        "Split {} and {} overlap: [{:?}, {:?}) vs [{:?}, {:?})",
647                        i,
648                        j,
649                        split_i.left,
650                        split_i.right,
651                        split_j.left,
652                        split_j.right
653                    );
654                }
655            }
656        }
657
658        // Additional verification: ensure splits are sorted
659        for i in 1..splits.len() {
660            if !splits[i - 1].right.is_empty() && !splits[i].left.is_empty() {
661                assert!(
662                    splits[i - 1].right <= splits[i].left,
663                    "Splits are not in sorted order at index {}: {:?} > {:?}",
664                    i,
665                    splits[i - 1].right,
666                    splits[i].left
667                );
668            }
669        }
670    }
671}