use std::collections::{HashSet, VecDeque};
use std::pin::pin;
use std::sync::Arc;
use arrow::array::{RecordBatch, RecordBatchIterator};
use arrow::datatypes::{FieldRef, Schema, SchemaRef};
use futures::{FutureExt, Stream};
use lance::Dataset;
use lance::dataset::fragment::write::FragmentCreateBuilder;
use lance::dataset::transaction::{Operation, Transaction};
use lance::dataset::{CommitBuilder, NewColumnTransform, WriteMode as LanceWriteMode, WriteParams};
use lance::table::format::Fragment;
use lance_core::datatypes::{OnMissing, OnTypeMismatch, Schema as LanceSchema};
use nd_arrow_array::NdRecordBatch;
use nd_arrow_array::extension::{GLOBAL_ATTRIBUTES_FIELD, is_global_attributes_field};
use tokio::task::JoinHandle;
use crate::cast::Casting;
use crate::encode::{RowDecoder, encode_rows};
use crate::error::{Error, Result};
use crate::format::check_format;
use crate::options::ColumnOptions;
use crate::schema::{concat_conformed, holds_schema, merge_schemas, missing_fields, used_fields};
use crate::stats::{STATS_PREFIX, is_stats_name};
use crate::store::{StorageOptions, open_dataset, store_params};
use crate::widen::{promotions, widen};
use crate::write::{
FILE_VERSION, WriteMode, WriteOptions, next_group, prepare_batch, table_defaults,
table_has_stats,
};
pub struct NdWriter {
uri: String,
base: Option<Dataset>,
overwrite: bool,
defaults: ColumnOptions,
stats: bool,
options: WriteOptions,
schema: Option<(Schema, LanceSchema)>,
buffer: Vec<RecordBatch>,
buffered_bytes: usize,
pending: VecDeque<(JoinHandle<Result<Vec<Fragment>>>, usize)>,
fragments: Vec<Fragment>,
written: DiskRatio,
failed: Option<Arc<Error>>,
}
impl NdWriter {
#[tracing::instrument(skip_all, fields(uri = %uri, mode = ?options.mode), err)]
pub async fn create(uri: &str, options: &WriteOptions) -> Result<Self> {
let existing = open_existing(uri, &options.storage_options).await?;
let overwrite = match options.mode {
WriteMode::Create if existing.is_some() => {
return Err(Error::TableExists(uri.to_string()));
}
WriteMode::Create => false,
WriteMode::Overwrite => true,
WriteMode::Append => {
return Err(Error::InvalidArgument(
"use NdWriter::append to append to a table".to_string(),
));
}
};
Ok(Self {
uri: uri.to_string(),
base: existing,
overwrite,
defaults: options.column_defaults.clone().unwrap_or_default(),
stats: options.stats,
options: options.clone(),
schema: None,
buffer: Vec::new(),
buffered_bytes: 0,
pending: VecDeque::new(),
fragments: Vec::new(),
written: DiskRatio::default(),
failed: None,
})
}
#[tracing::instrument(skip_all, fields(uri = %uri), err)]
pub async fn append(uri: &str, options: &WriteOptions) -> Result<Self> {
let dataset = open_existing(uri, &options.storage_options)
.await?
.ok_or_else(|| Error::TableNotFound(uri.to_string()))?;
check_format(&dataset)?;
let defaults = table_defaults(&dataset, options)?;
let stats = table_has_stats(&dataset);
let schema = (Schema::from(dataset.schema()), dataset.schema().clone());
Ok(Self {
uri: uri.to_string(),
base: Some(dataset),
overwrite: false,
defaults,
stats,
options: options.clone(),
schema: Some(schema),
buffer: Vec::new(),
buffered_bytes: 0,
pending: VecDeque::new(),
fragments: Vec::new(),
written: DiskRatio::default(),
failed: None,
})
}
pub async fn write(&mut self, rows: impl Stream<Item = Result<NdRecordBatch>>) -> Result<()> {
self.check()?;
let mut rows = pin!(rows);
while let Some(group) = next_group(&mut rows, &self.options).await? {
let batch = encode_rows(&group)?;
self.promote(&batch).await?;
let table = self.schema.as_ref().map(|(arrow, _)| arrow);
let batch = prepare_batch(batch, &self.defaults, &self.options, self.stats, table)?;
self.push(batch).await?;
}
Ok(())
}
pub async fn write_batch(&mut self, batch: RecordBatch) -> Result<()> {
self.check()?;
self.write_encoded(batch).await
}
fn check(&self) -> Result<()> {
match &self.failed {
Some(error) => Err(Error::WriterFailed(error.clone())),
None => Ok(()),
}
}
fn fail_on(&mut self, result: Result<()>) -> Result<()> {
result.map_err(|error| {
let error = Arc::new(error);
self.failed = Some(error.clone());
Error::WriterFailed(error)
})
}
async fn write_encoded(&mut self, batch: RecordBatch) -> Result<()> {
if let Some(field) = batch.schema().fields().iter().find(|field| {
is_stats_name(field.name())
|| (field.name() == GLOBAL_ATTRIBUTES_FIELD && !is_global_attributes_field(field))
}) {
return Err(Error::SchemaMismatch(format!(
"variable name '{}' is reserved, also each name that starts with {STATS_PREFIX}",
field.name()
)));
}
RowDecoder::try_new(&batch)?;
if batch.num_rows() == 0 {
return Ok(());
}
let batch = nullable_global_attributes(batch)?;
self.promote(&batch).await?;
let table = self.schema.as_ref().map(|(arrow, _)| arrow);
let batch = prepare_batch(batch, &self.defaults, &self.options, self.stats, table)?;
self.push(batch).await
}
async fn promote(&mut self, batch: &RecordBatch) -> Result<()> {
if self.options.casting != Casting::Promote {
return Ok(());
}
let Some((arrow, _)) = &self.schema else {
return Ok(());
};
let widenings = promotions(batch, arrow);
if widenings.is_empty() {
return Ok(());
}
let result = async {
let staged =
!(self.buffer.is_empty() && self.pending.is_empty() && self.fragments.is_empty());
if staged || self.base.is_none() {
self.commit().await?;
}
let dataset = self.base.as_mut().expect("a committed table");
for (variable, to) in &widenings {
widen(dataset, variable, to).boxed().await?;
}
self.schema = Some((Schema::from(dataset.schema()), dataset.schema().clone()));
Ok(())
}
.await;
self.fail_on(result)
}
async fn push(&mut self, batch: RecordBatch) -> Result<()> {
let grows = match &self.schema {
Some((arrow, _)) => !holds_schema(arrow, &batch.schema()),
None => true,
};
if grows {
let (arrow, lance) = match &self.schema {
Some((arrow, lance)) => (
merge_schemas(arrow, &batch.schema())?,
lance.merge(batch.schema().as_ref())?,
),
None => (
batch.schema().as_ref().clone(),
LanceSchema::try_from(batch.schema().as_ref())?,
),
};
let mut lance = lance;
lance.set_field_id(self.base_max_field_id());
self.schema = Some((arrow, lance));
}
self.buffered_bytes += batch.get_array_memory_size();
self.buffer.push(batch);
if self.written.on_disk(self.buffered_bytes) >= self.options.target_fragment_bytes as f64 {
let result = self.flush().boxed().await;
return self.fail_on(result);
}
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, fields(rows = self.buffer.len(), bytes = self.buffered_bytes))]
async fn flush(&mut self) -> Result<()> {
if self.buffer.is_empty() {
return Ok(());
}
self.evolve().await?;
let batches = std::mem::take(&mut self.buffer);
let memory = std::mem::take(&mut self.buffered_bytes);
let (arrow, lance) = self.schema.as_ref().expect("a buffered row has a schema");
let schemas: Vec<SchemaRef> = batches
.iter()
.map(|batch| batch.schema())
.collect::<HashSet<_>>()
.into_iter()
.collect();
let present = Arc::new(used_fields(arrow, &schemas));
let batch = concat_conformed(&present, &batches)?;
let projected =
lance.project_by_schema(present.as_ref(), OnMissing::Error, OnTypeMismatch::Error)?;
let uri = self.uri.clone();
let store_params = store_params(&self.options.storage_options);
let task = tokio::spawn(async move {
let params = WriteParams {
mode: LanceWriteMode::Append,
data_storage_version: Some(FILE_VERSION),
store_params,
..Default::default()
};
let reader = RecordBatchIterator::new([Ok(batch)], present);
Ok(FragmentCreateBuilder::new(&uri)
.schema(&projected)
.write_params(¶ms)
.write_fragments(reader)
.await?)
});
self.pending.push_back((task, memory));
while self.pending.len() > self.options.write_parallelism.max(1) {
self.wait_oldest().await?;
}
Ok(())
}
async fn evolve(&mut self) -> Result<()> {
let (Some(base), Some((arrow, _))) = (self.base.as_mut(), self.schema.as_ref()) else {
return Ok(());
};
if self.overwrite {
return Ok(());
}
let missing = missing_fields(Schema::from(base.schema()).fields(), arrow.fields());
if missing.is_empty() {
return Ok(());
}
base.add_columns(
NewColumnTransform::AllNulls(Arc::new(Schema::new(missing))),
None,
None,
)
.boxed()
.await?;
self.schema = Some((Schema::from(base.schema()), base.schema().clone()));
Ok(())
}
async fn wait_oldest(&mut self) -> Result<()> {
let Some((task, memory)) = self.pending.pop_front() else {
return Ok(());
};
let fragments = task.await??;
self.written.add(memory, &fragments);
self.fragments.extend(fragments);
Ok(())
}
fn base_max_field_id(&self) -> Option<i32> {
match (&self.base, self.overwrite) {
(Some(dataset), false) => Some(dataset.manifest.max_field_id()),
_ => None,
}
}
#[tracing::instrument(skip_all, fields(uri = %self.uri), err)]
pub async fn finish(mut self) -> Result<Dataset> {
if let Some(error) = &self.failed {
return Err(Error::WriterFailed(error.clone()));
}
self.commit().boxed().await
}
#[tracing::instrument(level = "debug", skip_all, fields(uri = %self.uri))]
async fn commit(&mut self) -> Result<Dataset> {
self.flush().boxed().await?;
while !self.pending.is_empty() {
self.wait_oldest().await?;
}
let Some((_, schema)) = self.schema.clone().filter(|_| !self.fragments.is_empty()) else {
return match (&self.base, self.overwrite) {
(Some(dataset), false) => Ok(dataset.clone()),
_ => Err(Error::InvalidArgument(format!(
"no rows to create the table at {}",
self.uri
))),
};
};
let fragments = std::mem::take(&mut self.fragments);
let dataset = match (self.base.take(), self.overwrite) {
(None, overwrite) => {
let storage_options = &self.options.storage_options;
if !overwrite && open_existing(&self.uri, storage_options).await?.is_some() {
return Err(Error::TableExists(self.uri.clone()));
}
let operation = overwrite_operation(numbered(fragments, 0), schema);
let mut builder =
CommitBuilder::new(self.uri.as_str()).with_storage_format(FILE_VERSION);
if let Some(params) = store_params(storage_options) {
builder = builder.with_store_params(params);
}
builder
.execute(Transaction::new(0, operation, None))
.await?
}
(Some(base), true) => {
let version = base.version().version;
let operation = overwrite_operation(numbered(fragments, 0), schema);
CommitBuilder::new(Arc::new(base))
.execute(Transaction::new(version, operation, None))
.await?
}
(Some(base), false) if schema.max_field_id() == base.schema().max_field_id() => {
commit_rows(base, fragments, &schema).boxed().await?
}
(Some(base), false) => {
let version = base.version().version;
let mut all: Vec<Fragment> = base
.get_fragments()
.iter()
.map(|f| f.metadata().clone())
.collect();
let first_id = base.manifest.max_fragment_id().map_or(0, |id| id + 1);
all.extend(numbered(fragments, first_id));
let operation = Operation::Merge {
fragments: all,
schema,
preserves_nullability: true,
};
CommitBuilder::new(Arc::new(base))
.execute(Transaction::new(version, operation, None))
.await?
}
};
tracing::info!(version = dataset.version().version, "committed the rows");
self.base = Some(dataset.clone());
self.overwrite = false;
self.schema = Some((Schema::from(dataset.schema()), dataset.schema().clone()));
Ok(dataset)
}
pub fn abort(self) {}
}
impl Drop for NdWriter {
fn drop(&mut self) {
for (task, _) in &self.pending {
task.abort();
}
}
}
fn nullable_global_attributes(batch: RecordBatch) -> Result<RecordBatch> {
let schema = batch.schema();
let Some((index, field)) = schema.column_with_name(GLOBAL_ATTRIBUTES_FIELD) else {
return Ok(batch);
};
if field.is_nullable() {
return Ok(batch);
}
let mut fields: Vec<FieldRef> = schema.fields().iter().cloned().collect();
fields[index] = Arc::new(field.clone().with_nullable(true));
let schema = Schema::new_with_metadata(fields, schema.metadata().clone());
Ok(RecordBatch::try_new(
Arc::new(schema),
batch.columns().to_vec(),
)?)
}
#[derive(Debug, Default)]
struct DiskRatio {
memory: u64,
disk: u64,
}
impl DiskRatio {
const MIN: f64 = 1.0 / 8.0;
fn add(&mut self, memory: usize, fragments: &[Fragment]) {
let files = fragments.iter().flat_map(|fragment| fragment.files.iter());
let sizes: Option<Vec<u64>> = files
.map(|file| file.file_size_bytes.get().map(|size| size.get()))
.collect();
if let Some(sizes) = sizes {
self.memory += memory as u64;
self.disk += sizes.iter().sum::<u64>();
}
}
fn on_disk(&self, memory: usize) -> f64 {
let ratio = if self.memory == 0 {
1.0
} else {
(self.disk as f64 / self.memory as f64).max(Self::MIN)
};
memory as f64 * ratio
}
}
const MAX_COMMIT_ATTEMPTS: usize = 10;
#[tracing::instrument(level = "debug", skip_all, fields(fragments = fragments.len()))]
async fn commit_rows(
mut base: Dataset,
fragments: Vec<Fragment>,
staged: &LanceSchema,
) -> Result<Dataset> {
for attempt in 1..=MAX_COMMIT_ATTEMPTS {
check_fields(&base, &fragments, staged)?;
let operation = Operation::Update {
removed_fragment_ids: Vec::new(),
updated_fragments: Vec::new(),
new_fragments: fragments.clone(),
fields_modified: Vec::new(),
compacted_sstables: Vec::new(),
fields_for_preserving_frag_bitmap: Vec::new(),
update_mode: None,
inserted_rows_filter: None,
updated_fragment_offsets: None,
};
let version = base.version().version;
match CommitBuilder::new(Arc::new(base.clone()))
.execute(Transaction::new(version, operation, None))
.await
{
Ok(dataset) => return Ok(dataset),
Err(lance::Error::RetryableCommitConflict { .. }) if attempt < MAX_COMMIT_ATTEMPTS => {
tracing::debug!(attempt, "a concurrent commit preempted the rows, try again");
base.checkout_latest().await?;
}
Err(error) => return Err(error.into()),
}
}
unreachable!("the last attempt returns")
}
fn check_fields(table: &Dataset, fragments: &[Fragment], staged: &LanceSchema) -> Result<()> {
let ids: HashSet<i32> = table.schema().field_ids().into_iter().collect();
let lost = fragments
.iter()
.flat_map(|fragment| fragment.files.iter())
.flat_map(|file| file.fields.iter())
.find(|id| **id >= 0 && !ids.contains(*id));
match lost {
Some(id) => Err(Error::SchemaMismatch(format!(
"the table changed or dropped '{}' during the write, nothing is committed",
staged
.field_path(*id)
.unwrap_or_else(|_| format!("field {id}"))
))),
None => Ok(()),
}
}
fn numbered(mut fragments: Vec<Fragment>, first_id: u64) -> Vec<Fragment> {
for (id, fragment) in (first_id..).zip(&mut fragments) {
fragment.id = id;
}
fragments
}
fn overwrite_operation(fragments: Vec<Fragment>, schema: LanceSchema) -> Operation {
Operation::Overwrite {
fragments,
schema,
config_upsert_values: None,
initial_bases: None,
}
}
pub(crate) async fn open_existing(uri: &str, options: &StorageOptions) -> Result<Option<Dataset>> {
match open_dataset(uri, options).await {
Ok(dataset) => Ok(Some(dataset)),
Err(lance::Error::DatasetNotFound { .. }) => Ok(None),
Err(error) => Err(error.into()),
}
}