use cobble::{CompactionMode, Config, DbBuilder, RemoteCompactionFailureMode, VolumeDescriptor};
use cobble_table::catalog::{Catalog, FileCatalog, FileCatalogConfig, TableIdentifier};
use cobble_table::{
LogicalType, ReadOnlyTableBuilder, SchemaChange, TableSchema, Value, register_schema_transforms,
};
use size::Size;
use std::net::{TcpListener, TcpStream};
use std::process::{Child, Command};
use std::sync::Arc;
use std::time::{Duration, Instant};
struct Compactor(Child);
impl Compactor {
fn assert_running(&mut self) {
assert!(self.0.try_wait().unwrap().is_none(), "CLI compactor exited");
}
}
impl Drop for Compactor {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn cli_compactors_materialize_builtin_table_transforms() {
for dedicated in [false, true] {
let root = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap().to_string();
drop(listener);
let config = Config {
volumes: VolumeDescriptor::single_volume(format!("file://{}", root.path().display())),
total_buckets: 1,
l0_file_limit: 2,
memtable_capacity: Size::from_mib(1),
block_cache_size: Size::from_mib(1),
base_file_size: Size::from_mib(1),
value_separation_threshold: Some(Size::from_const(1)),
snapshot_disable_incremental_base_link: true,
compaction_mode: if dedicated {
CompactionMode::Dedicated
} else {
CompactionMode::Embedded
},
compaction_remote_addr: (!dedicated).then_some(address.clone()),
compaction_remote_failure_mode: RemoteCompactionFailureMode::Skip,
compaction_dedicated_poll_interval_ms: 50,
compaction_threads: 1,
log_console: false,
..Config::default()
};
let config_path = root.path().join("config.json");
std::fs::write(&config_path, serde_json::to_vec(&config).unwrap()).unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_cobble-cli"));
if dedicated {
command
.arg("compact")
.arg("--config")
.arg(&config_path)
.arg(root.path().join("shard"));
} else {
command
.arg("remote-compactor")
.arg("--config")
.arg(&config_path)
.arg("--bind")
.arg(&address);
}
let mut compactor = if dedicated {
None
} else {
let mut child = Compactor(command.spawn().unwrap());
let deadline = Instant::now() + Duration::from_secs(60);
while TcpStream::connect(&address).is_err() {
child.assert_running();
assert!(Instant::now() < deadline, "remote CLI did not listen");
std::thread::sleep(Duration::from_millis(50));
}
Some(child)
};
let catalog = FileCatalog::open(&config, FileCatalogConfig::new("catalog")).unwrap();
catalog.create_namespace(vec!["test".into()]).unwrap();
let identifier = TableIdentifier::new(["test"], "numbers");
catalog
.create_table(
identifier.clone(),
TableSchema::builder()
.field("id", LogicalType::int64())
.field("value", LogicalType::int16())
.primary_key(["id"])
.bucket_key(["id"])
.build()
.unwrap(),
)
.unwrap();
let builder = DbBuilder::new(config.clone())
.db_id("shard")
.bucket_ranges(vec![0..=0]);
register_schema_transforms(&builder).unwrap();
let db = Arc::new(builder.open().unwrap());
let old = catalog
.materialize_table(Arc::clone(&db), &identifier)
.unwrap();
for id in 0..64 {
old.put(&[Value::Int64(id), Value::Int16(id as i16)])
.unwrap();
}
old.snapshot_and_wait().unwrap();
drop(old);
for logical_type in [LogicalType::int32(), LogicalType::int64().nullable()] {
catalog
.evolve_schema(
&identifier,
vec![SchemaChange::AlterFieldType {
field_name: "value".into(),
logical_type,
}],
)
.unwrap();
}
let table = catalog
.materialize_table(Arc::clone(&db), &identifier)
.unwrap();
let target_schema = db.current_schema().version();
for batch in 0..2 {
for id in batch * 16..(batch + 1) * 16 {
table
.put(&[Value::Int64(id), Value::Int64(300_000 + id)])
.unwrap();
}
table.snapshot_and_wait().unwrap();
}
if dedicated {
compactor = Some(Compactor(command.spawn().unwrap()));
}
let mut compactor = compactor.unwrap();
let deadline = Instant::now() + Duration::from_secs(60);
let snapshot = loop {
compactor.assert_running();
let snapshot = table.snapshot_and_wait().unwrap();
let manifest: serde_json::Value = serde_json::from_slice(
&cobble::test_utils::read_metadata_payload_from_path_for_test(
snapshot
.manifest_path
.strip_prefix("file://")
.unwrap_or(&snapshot.manifest_path),
)
.unwrap(),
)
.unwrap();
let files = manifest["tree_levels"]
.as_array()
.unwrap()
.iter()
.flat_map(|tree| tree.as_array().unwrap())
.flat_map(|level| level["files"].as_array().unwrap())
.collect::<Vec<_>>();
if !files.is_empty() && files.iter().all(|file| file["schema_id"] == target_schema) {
break snapshot;
}
assert!(
Instant::now() < deadline,
"CLI did not materialize old SSTs (dedicated={dedicated})"
);
std::thread::sleep(Duration::from_millis(250));
};
let name = snapshot
.column_families
.iter()
.find(|(_, family)| {
family
.options
.metadata
.as_ref()
.is_some_and(|metadata| metadata["format"] == "cobble-table")
})
.unwrap()
.0
.clone();
let reader = ReadOnlyTableBuilder::new(config)
.table_name(name)
.shard_snapshot("shard", snapshot.snapshot_id)
.open()
.unwrap();
for id in 0..64 {
let mut key = reader.key_builder();
key.push(Value::Int64(id));
let key = key.build().unwrap();
let value = if id < 32 { 300_000 + id } else { id };
assert_eq!(
reader.get(&key).unwrap(),
Some(vec![Value::Int64(id), Value::Int64(value)])
);
}
drop(reader);
drop(table);
db.close().unwrap();
}
}