Skip to main content

laminar_connectors/mongodb/sink/
mod.rs

1//! `MongoDB` sink connector implementation.
2//!
3//! Implements [`SinkConnector`] for writing Arrow `RecordBatch` data to
4//! `MongoDB` collections. Supports insert, upsert, and CDC replay
5//! write modes, with optional time series collection support.
6//!
7//! Writes are bounded by fixed retained-memory and bulk limits and flushed when
8//! a bound is reached, `flush_interval` elapses, or shutdown begins.
9//! Keyed and CDC flushes use one ordered `MongoDB` 8+ bulk operation, preserving
10//! input order without a network round trip per row.
11
12use std::future::Future;
13use std::sync::Arc;
14use std::time::Duration;
15
16use arrow_array::RecordBatch;
17use arrow_schema::{DataType, SchemaRef};
18use async_trait::async_trait;
19use tracing::{debug, info};
20
21use crate::changelog::collapse_changelog;
22use crate::config::{ConnectorConfig, ConnectorState};
23use crate::connector::{
24    ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
25    SinkInputMode, SinkTopology, WriteResult,
26};
27use crate::error::ConnectorError;
28use laminar_core::changelog::WEIGHT_COLUMN;
29
30use super::config::MongoDbSinkConfig;
31use super::metrics::MongoDbSinkMetrics;
32use super::timeseries::CollectionKind;
33use super::write_model::WriteMode;
34
35// One budget covers retained Arrow, materialized BSON/write models, conversion scratch, and the
36// driver's command serialization. The multiplier reserves space for the materialized document,
37// the encoded command, and conversion/driver scratch instead of pretending those allocations are
38// independent. These are implementation limits, not user tuning knobs.
39const MAX_BUFFERED_RETAINED_BYTES: usize = 16 * 1024 * 1024;
40const MAX_SINK_WORKING_SET_BYTES: usize = 64 * 1024 * 1024;
41const MAX_STANDARD_DOCUMENT_BYTES: usize = 16 * 1024 * 1024;
42const MAX_TIMESERIES_DOCUMENT_BYTES: usize = 4 * 1024 * 1024;
43const MAX_DOCUMENTS_PER_FLUSH: usize = 1_000;
44const MATERIALIZED_BYTE_CHARGE: usize = 3;
45const WRITE_MODEL_OVERHEAD_BYTES: usize = 256;
46const CDC_ROW_OVERHEAD_BYTES: usize = 512;
47const MONGODB_8_WIRE_VERSION: i32 = 25;
48const DEFAULT_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
49const MIN_WRITE_TIMEOUT: Duration = Duration::from_millis(100);
50const MAX_DRIVER_TIMEOUT_HEADROOM: Duration = Duration::from_secs(1);
51const NAMESPACE_EXISTS_CODE: i32 = 48;
52
53mod bulk;
54mod cdc;
55pub(super) mod conversion;
56mod failure;
57mod lifecycle;
58mod schema_resolution;
59mod validation;
60
61#[cfg(test)]
62use bulk::{await_mongo_sink_operation, MongoOperationOutcome};
63use bulk::{cdc_bulk_models, CdcWrite};
64use conversion::{
65    account_bson_document, accumulate_write_result, arrow_value_to_bson, cdc_row_value,
66    checked_converted_total, clamp_client_timeout, encoded_document_size, ensure_working_set,
67    json_to_bson, json_to_bson_document, mongo_partial_batch_error, requires_preflush,
68    retained_batch_bytes, validate_cdc_document_key, validate_cdc_replacement_key,
69};
70#[cfg(test)]
71use conversion::{timestamp_millis, working_set_charge};
72#[cfg(test)]
73use failure::{
74    classify_mongo_bulk_facts, mongo_bulk_failure_facts, MongoBulkDisposition,
75    MongoBulkFailureFacts, MongoBulkFailureShape,
76};
77use failure::{classify_mongo_bulk_failure, MongoBulkFailure};
78pub(super) use validation::harden_mongodb_tls;
79#[cfg(test)]
80use validation::is_namespace_exists_code;
81use validation::{
82    is_namespace_exists, is_supported_mongodb_arrow_type, validate_existing_timeseries_spec,
83};
84
85/// Writes Arrow record batches to a `MongoDB` collection.
86///
87/// Supports standard and time series collections in insert, upsert, and CDC replay modes.
88pub struct MongoDbSink {
89    config: MongoDbSinkConfig,
90    schema: SchemaRef,
91    binding: Option<crate::schema::resolution::SchemaBinding>,
92    state: ConnectorState,
93    buffer: Vec<RecordBatch>,
94    buffered_rows: usize,
95    buffered_retained_bytes: usize,
96    write_timeout: Duration,
97    metrics: MongoDbSinkMetrics,
98
99    /// Admission authority and terminal observer for bulk operations owned by this generation.
100    task_owner: ConnectorTaskOwner,
101    task_tracker: ConnectorTaskTracker,
102
103    client: Option<mongodb::Client>,
104    collection: Option<mongodb::Collection<mongodb::bson::Document>>,
105}
106
107impl MongoDbSink {
108    /// Creates a new `MongoDB` sink connector.
109    #[must_use]
110    pub fn new(
111        schema: SchemaRef,
112        config: MongoDbSinkConfig,
113        registry: Option<&prometheus::Registry>,
114    ) -> Self {
115        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
116        Self {
117            config,
118            schema,
119            binding: None,
120            state: ConnectorState::Created,
121            buffer: Vec::with_capacity(4),
122            buffered_rows: 0,
123            buffered_retained_bytes: 0,
124            write_timeout: DEFAULT_WRITE_TIMEOUT,
125            metrics: MongoDbSinkMetrics::new(registry),
126            task_owner,
127            task_tracker,
128            client: None,
129            collection: None,
130        }
131    }
132
133    /// Creates a new sink from a generic [`ConnectorConfig`].
134    ///
135    /// # Errors
136    ///
137    /// Returns `ConnectorError` if the configuration is invalid.
138    pub fn from_config(
139        schema: SchemaRef,
140        config: &ConnectorConfig,
141    ) -> Result<Self, ConnectorError> {
142        let mongo_config = MongoDbSinkConfig::from_config(config)?;
143        Self::validate_schema(&schema, &mongo_config)?;
144        let mut sink = Self::new(schema, mongo_config, None);
145        sink.write_timeout = Self::configured_write_timeout(config)?;
146        Ok(sink)
147    }
148
149    pub(crate) fn from_connector_config(
150        config: &ConnectorConfig,
151        registry: Option<&prometheus::Registry>,
152    ) -> Result<Self, ConnectorError> {
153        let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
154        let mut sink = Self::new(schema, sink_config, registry);
155        sink.write_timeout = write_timeout;
156        Ok(sink)
157    }
158
159    fn decode_connector_config(
160        config: &ConnectorConfig,
161    ) -> Result<(MongoDbSinkConfig, SchemaRef, Duration), ConnectorError> {
162        let sink_config = MongoDbSinkConfig::from_config(config)?;
163        if config.get("_arrow_schema").is_none() {
164            return Err(ConnectorError::ConfigurationError(
165                "MongoDB sink requires the engine-injected '_arrow_schema'".into(),
166            ));
167        }
168        let schema = config.arrow_schema().ok_or_else(|| {
169            ConnectorError::ConfigurationError(
170                "invalid MongoDB sink '_arrow_schema' encoding".into(),
171            )
172        })?;
173        Self::validate_schema(&schema, &sink_config)?;
174        let write_timeout = Self::configured_write_timeout(config)?;
175        Ok((sink_config, schema, write_timeout))
176    }
177
178    fn configured_write_timeout(config: &ConnectorConfig) -> Result<Duration, ConnectorError> {
179        let timeout = config
180            .get_parsed::<u64>("sink.write.timeout.ms")?
181            .map_or(DEFAULT_WRITE_TIMEOUT, Duration::from_millis);
182        if timeout < MIN_WRITE_TIMEOUT {
183            return Err(ConnectorError::ConfigurationError(format!(
184                "MongoDB sink.write.timeout.ms must be at least {}",
185                MIN_WRITE_TIMEOUT.as_millis()
186            )));
187        }
188        Ok(timeout)
189    }
190
191    fn driver_timeout(&self) -> Duration {
192        let headroom = (self.write_timeout / 5).min(MAX_DRIVER_TIMEOUT_HEADROOM);
193        self.write_timeout.checked_sub(headroom).unwrap()
194    }
195
196    fn validate_schema(
197        schema: &SchemaRef,
198        config: &MongoDbSinkConfig,
199    ) -> Result<(), ConnectorError> {
200        if schema.fields().is_empty() {
201            return Err(ConnectorError::ConfigurationError(
202                "MongoDB sink schema must contain at least one field".into(),
203            ));
204        }
205
206        let mut names = std::collections::HashSet::with_capacity(schema.fields().len());
207        for field in schema.fields() {
208            if !names.insert(field.name()) {
209                return Err(ConnectorError::ConfigurationError(format!(
210                    "MongoDB sink schema contains duplicate field '{}'",
211                    field.name()
212                )));
213            }
214            if !is_supported_mongodb_arrow_type(field.data_type()) {
215                return Err(ConnectorError::ConfigurationError(format!(
216                    "MongoDB sink field '{}' has unsupported Arrow type {:?}",
217                    field.name(),
218                    field.data_type()
219                )));
220            }
221        }
222
223        match &config.write_mode {
224            WriteMode::Upsert { key_fields } => {
225                for key in key_fields {
226                    match schema.field_with_name(key) {
227                        Ok(field) if !field.is_nullable() => {}
228                        Ok(_) => {
229                            return Err(ConnectorError::ConfigurationError(format!(
230                                "upsert key field '{key}' must be non-nullable"
231                            )));
232                        }
233                        Err(_) => {
234                            return Err(ConnectorError::ConfigurationError(format!(
235                                "upsert key field '{key}' is not present in MongoDB sink schema"
236                            )));
237                        }
238                    }
239                }
240            }
241            WriteMode::CdcReplay => {
242                for field in conversion::CDC_REPLAY_FIELDS {
243                    match schema.field_with_name(field) {
244                        Ok(schema_field) if schema_field.data_type() == &DataType::Utf8 => {}
245                        Ok(schema_field) => {
246                            return Err(ConnectorError::ConfigurationError(format!(
247                                "MongoDB CDC replay field '{field}' must be Utf8, got {:?}",
248                                schema_field.data_type()
249                            )));
250                        }
251                        Err(_) => {
252                            return Err(ConnectorError::ConfigurationError(format!(
253                                "MongoDB CDC replay input must be MongoDB history records \
254                                 (output.mode=history); missing '{field}'"
255                            )));
256                        }
257                    }
258                }
259                for field in ["operation", "database"] {
260                    if schema
261                        .field_with_name(field)
262                        .is_ok_and(arrow_schema::Field::is_nullable)
263                    {
264                        return Err(ConnectorError::ConfigurationError(format!(
265                            "MongoDB CDC replay field '{field}' must be non-nullable"
266                        )));
267                    }
268                }
269                // History records are immutable facts. A weighted changelog would carry
270                // retractions that replay cannot apply faithfully.
271                if let Some(field) = schema.fields().iter().find(|field| {
272                    field.name().eq_ignore_ascii_case(WEIGHT_COLUMN)
273                        || field.name().eq_ignore_ascii_case("_op")
274                }) {
275                    return Err(ConnectorError::ConfigurationError(format!(
276                        "MongoDB CDC replay consumes append-only history records; input column \
277                         '{}' marks a changelog it cannot apply",
278                        field.name()
279                    )));
280                }
281            }
282            WriteMode::Insert => {}
283        }
284
285        if let CollectionKind::TimeSeries(time_series) = &config.collection_kind {
286            let time_field = schema
287                .field_with_name(&time_series.time_field)
288                .map_err(|_| {
289                    ConnectorError::ConfigurationError(format!(
290                        "time series field '{}' is not present in MongoDB sink schema",
291                        time_series.time_field
292                    ))
293                })?;
294            if !matches!(time_field.data_type(), DataType::Timestamp(..)) {
295                return Err(ConnectorError::ConfigurationError(format!(
296                    "MongoDB time series field '{}' must be an Arrow Timestamp",
297                    time_series.time_field
298                )));
299            }
300            if time_field.is_nullable() {
301                return Err(ConnectorError::ConfigurationError(format!(
302                    "MongoDB time series field '{}' must be non-nullable",
303                    time_series.time_field
304                )));
305            }
306            if let Some(meta_field) = &time_series.meta_field {
307                if schema.field_with_name(meta_field).is_err() {
308                    return Err(ConnectorError::ConfigurationError(format!(
309                        "time series metadata field '{meta_field}' is not present in MongoDB sink schema"
310                    )));
311                }
312            }
313        }
314
315        Ok(())
316    }
317
318    fn apply_connector_config(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
319        let (sink_config, schema, write_timeout) = Self::decode_connector_config(config)?;
320        self.config = sink_config;
321        self.schema = schema;
322        self.write_timeout = write_timeout;
323        Ok(())
324    }
325
326    /// Returns a reference to the sink configuration.
327    #[must_use]
328    pub fn config(&self) -> &MongoDbSinkConfig {
329        &self.config
330    }
331
332    /// Returns the number of buffered rows.
333    #[must_use]
334    pub fn buffered_rows(&self) -> usize {
335        self.buffered_rows
336    }
337
338    fn retain_batch(&mut self, batch: RecordBatch, retained_bytes: usize) {
339        self.buffered_rows = self.buffered_rows.saturating_add(batch.num_rows());
340        self.buffered_retained_bytes = self.buffered_retained_bytes.saturating_add(retained_bytes);
341        self.buffer.push(batch);
342    }
343
344    fn take_buffer(&mut self) -> (Vec<RecordBatch>, usize) {
345        let retained_bytes = self.buffered_retained_bytes;
346        self.buffered_rows = 0;
347        self.buffered_retained_bytes = 0;
348        (std::mem::take(&mut self.buffer), retained_bytes)
349    }
350
351    /// Converts CDC rows one at a time and drops each JSON staging value before advancing.
352    fn batches_to_cdc_writes(
353        batches: &[RecordBatch],
354        retained_bytes: usize,
355        working_set_limit: usize,
356        expected_namespace: &str,
357    ) -> Result<(Vec<CdcWrite>, u64), ConnectorError> {
358        let row_count = batches.iter().map(RecordBatch::num_rows).sum();
359        let mut writes = Vec::with_capacity(row_count);
360        let mut encoded_bytes = 0;
361
362        for batch in batches {
363            for row_idx in 0..batch.num_rows() {
364                let (value, staging_bytes) = cdc_row_value(batch, row_idx)?;
365                ensure_working_set(
366                    retained_bytes,
367                    encoded_bytes,
368                    writes.len().saturating_add(1),
369                    staging_bytes,
370                    working_set_limit,
371                    "MongoDB CDC flush",
372                )?;
373                let (mut row_writes, row_bytes) = Self::prepare_cdc_writes(
374                    std::slice::from_ref(&value),
375                    usize::MAX,
376                    expected_namespace,
377                )?;
378                let row_bytes = usize::try_from(row_bytes).map_err(|_| {
379                    ConnectorError::ConfigurationError(
380                        "MongoDB CDC encoded byte count exceeds this platform".into(),
381                    )
382                })?;
383                encoded_bytes = checked_converted_total(
384                    encoded_bytes,
385                    row_bytes,
386                    usize::MAX,
387                    "MongoDB CDC flush",
388                )?;
389                let write = row_writes.pop().ok_or_else(|| {
390                    ConnectorError::Internal("MongoDB CDC row produced no write disposition".into())
391                })?;
392                ensure_working_set(
393                    retained_bytes,
394                    encoded_bytes,
395                    writes.len().saturating_add(1),
396                    staging_bytes,
397                    working_set_limit,
398                    "MongoDB CDC flush",
399                )?;
400                writes.push(write);
401            }
402        }
403
404        Ok((writes, encoded_bytes))
405    }
406
407    /// Arrow batches → BSON documents directly (no `serde_json::Value` hop), for the
408    /// insert/upsert paths. Returns `(docs, byte_estimate)`.
409    fn batches_to_bson_docs(
410        batches: &[RecordBatch],
411        retained_bytes: usize,
412        working_set_limit: usize,
413        document_limit: usize,
414    ) -> Result<(Vec<mongodb::bson::Document>, u64), ConnectorError> {
415        let row_count = batches.iter().map(RecordBatch::num_rows).sum();
416        let mut docs = Vec::with_capacity(row_count);
417        let mut bytes: u64 = 0;
418        for batch in batches {
419            let schema = batch.schema();
420            for row in 0..batch.num_rows() {
421                let mut doc = mongodb::bson::Document::new();
422                for (col_idx, field) in schema.fields().iter().enumerate() {
423                    let value = arrow_value_to_bson(batch.column(col_idx), row)?;
424                    doc.insert(field.name().clone(), value);
425                }
426                let document_bytes =
427                    encoded_document_size(&doc, document_limit, "MongoDB sink document")?;
428                bytes = checked_converted_total(
429                    bytes,
430                    document_bytes,
431                    usize::MAX,
432                    "MongoDB sink flush",
433                )?;
434                ensure_working_set(
435                    retained_bytes,
436                    bytes,
437                    docs.len().saturating_add(1),
438                    0,
439                    working_set_limit,
440                    "MongoDB sink flush",
441                )?;
442                docs.push(doc);
443            }
444        }
445        Ok((docs, bytes))
446    }
447
448    /// Internal flush that returns a [`WriteResult`] with actual counts.
449    ///
450    /// Both `write_batch` (on auto-flush) and `flush` delegate here.
451    async fn flush_inner(&mut self) -> Result<WriteResult, ConnectorError> {
452        if self.buffer.is_empty() {
453            return Ok(WriteResult::new(0, 0));
454        }
455
456        if let Some(expected) = &self.binding {
457            let client = self.client.as_ref().ok_or_else(|| {
458                ConnectorError::ConnectionFailed("MongoDB activated client is absent".into())
459            })?;
460            let current = tokio::time::timeout(
461                self.write_timeout,
462                schema_resolution::inspect(
463                    &client.database(&self.config.database),
464                    &ConnectorConfig::new("mongodb"),
465                    &self.config,
466                    self.schema.clone(),
467                ),
468            )
469            .await
470            .map_err(|_| {
471                ConnectorError::Timeout(
472                    u64::try_from(self.write_timeout.as_millis()).unwrap_or(u64::MAX),
473                )
474            })??;
475            if expected.value.is_some() && expected.value != current.value {
476                return Err(ConnectorError::SchemaMismatch("MongoDB collection identity or validator drifted before flush; restart with a migrated contract".into()));
477            }
478            self.binding = Some(current);
479        }
480        // Drain before any await. A timeout makes the bulk-write outcome unknown, retires this
481        // connector generation, and replays from the last durable engine checkpoint.
482        let (mut pending, retained_bytes) = self.take_buffer();
483        let write_result: Result<(usize, u64), ConnectorError> =
484            if matches!(self.config.write_mode, WriteMode::CdcReplay) {
485                let target = format!("{}.{}", self.config.database, self.config.collection);
486                let source = self
487                    .config
488                    .replay_source_namespace
489                    .as_deref()
490                    .unwrap_or(&target);
491                match Self::batches_to_cdc_writes(
492                    &pending,
493                    retained_bytes,
494                    MAX_SINK_WORKING_SET_BYTES,
495                    source,
496                ) {
497                    Ok((writes, bytes)) => {
498                        let count = writes.len();
499                        pending.clear();
500                        self.execute_cdc_writes(writes)
501                            .await
502                            .map(|()| (count, bytes))
503                    }
504                    Err(error) => Err(error),
505                }
506            } else {
507                match Self::collapse_changelog_buffer_for_upsert(&self.config, &mut pending)
508                    .and_then(|collapsed| {
509                        let retained_bytes = pending.iter().fold(0_usize, |total, batch| {
510                            total.saturating_add(retained_batch_bytes(batch))
511                        });
512                        Self::batches_to_bson_docs(
513                            &pending,
514                            retained_bytes,
515                            MAX_SINK_WORKING_SET_BYTES,
516                            if matches!(&self.config.collection_kind, CollectionKind::TimeSeries(_))
517                            {
518                                MAX_TIMESERIES_DOCUMENT_BYTES
519                            } else {
520                                MAX_STANDARD_DOCUMENT_BYTES
521                            },
522                        )
523                        .map(|(docs, bytes)| (docs, collapsed, bytes))
524                    }) {
525                    Ok((docs, collapsed, bytes)) => {
526                        let count = docs.len();
527                        pending.clear();
528                        self.write_bson_docs(docs, collapsed, bytes)
529                            .await
530                            .map(|()| (count, bytes))
531                    }
532                    Err(error) => Err(error),
533                }
534            };
535
536        pending.clear();
537        self.buffer = pending;
538
539        let (doc_count, byte_estimate) = write_result?;
540        self.metrics.record_flush(doc_count as u64, byte_estimate);
541        Ok(WriteResult::new(doc_count, byte_estimate))
542    }
543
544    /// Collapse a Z-set changelog buffer (an incremental MV: rows carry `__weight`) into a single
545    /// key-unique `{U,D}` batch before upsert, so retract+insert nets per key and a removed group
546    /// becomes a delete. No-op unless the write mode is upsert and the buffer is a Z-set changelog.
547    /// `Ok(true)` if the buffer was a Z-set changelog and got collapsed; `Ok(false)` for a plain
548    /// (non-changelog) upsert, so the caller doesn't interpret `_op` on rows we didn't produce.
549    fn collapse_changelog_buffer_for_upsert(
550        config: &MongoDbSinkConfig,
551        buffer: &mut Vec<RecordBatch>,
552    ) -> Result<bool, ConnectorError> {
553        let key_fields = match &config.write_mode {
554            WriteMode::Upsert { key_fields } => key_fields.clone(),
555            _ => return Ok(false),
556        };
557        // Gate on buffer[0] — it supplies the concat schema (concat requires all batches match it);
558        // `any` could pass on a later batch's weight while buffer[0] lacks it, failing the concat.
559        let Some(first) = buffer.first() else {
560            return Ok(false);
561        };
562        if first.schema().index_of(WEIGHT_COLUMN).is_err() {
563            return Ok(false);
564        }
565        let schema = first.schema();
566        let combined = arrow_select::concat::concat_batches(&schema, buffer.iter())
567            .map_err(|e| ConnectorError::Internal(format!("concat changelog: {e}")))?;
568        *buffer = vec![collapse_changelog(&combined, &key_fields)?];
569        Ok(true)
570    }
571
572    async fn write_batch_with_retained_limit(
573        &mut self,
574        batch: &RecordBatch,
575        retained_limit: usize,
576    ) -> Result<WriteResult, ConnectorError> {
577        if self.state != ConnectorState::Running {
578            return Err(ConnectorError::InvalidState {
579                expected: ConnectorState::Running.to_string(),
580                actual: self.state.to_string(),
581            });
582        }
583        if batch.schema().as_ref() != self.schema.as_ref() {
584            return Err(ConnectorError::SchemaMismatch(format!(
585                "MongoDB sink expected {:?}, got {:?}",
586                self.schema,
587                batch.schema()
588            )));
589        }
590
591        let rows = batch.num_rows();
592        if rows == 0 {
593            return Ok(WriteResult::new(0, 0));
594        }
595
596        let retained_bytes = retained_batch_bytes(batch);
597        // Reject a single oversized input before flushing or otherwise mutating existing state.
598        let _ = requires_preflush(0, retained_bytes, retained_limit)?;
599
600        let mut offset = 0;
601        let mut result = WriteResult::new(0, 0);
602        while offset < rows {
603            let available_rows = MAX_DOCUMENTS_PER_FLUSH - self.buffered_rows;
604            let chunk_rows = available_rows.min(rows - offset);
605            let chunk = batch.slice(offset, chunk_rows);
606            let chunk_retained_bytes = retained_batch_bytes(&chunk);
607
608            if requires_preflush(
609                self.buffered_retained_bytes,
610                chunk_retained_bytes,
611                retained_limit,
612            )? {
613                let flushed = self
614                    .flush_inner()
615                    .await
616                    .map_err(|error| mongo_partial_batch_error(&result, error))?;
617                accumulate_write_result(&mut result, &flushed);
618            }
619
620            self.retain_batch(chunk, chunk_retained_bytes);
621            offset += chunk_rows;
622            if self.buffered_rows == MAX_DOCUMENTS_PER_FLUSH {
623                let flushed = self
624                    .flush_inner()
625                    .await
626                    .map_err(|error| mongo_partial_batch_error(&result, error))?;
627                accumulate_write_result(&mut result, &flushed);
628            }
629        }
630
631        Ok(result)
632    }
633}
634
635#[cfg(test)]
636mod tests;