Skip to main content

google_cloud_spanner/
partitioned_dml_transaction.rs

1// Copyright 2026 Google LLC
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//     https://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 crate::client::amend_request_options_for_lar;
16use crate::database_client::DatabaseClient;
17use crate::google::spanner::v1::result_set_stats::RowCount::RowCountLowerBound;
18use crate::model::transaction_options::PartitionedDml;
19use crate::model::{
20    BeginTransactionRequest, TransactionOptions, TransactionSelector, transaction_selector,
21};
22use crate::server_streaming::stream::PartialResultSetStream;
23use crate::statement::Statement;
24use crate::transaction_retry_policy::{
25    BasicTransactionRetryPolicy, TransactionRetryPolicy, retry_aborted,
26};
27use google_cloud_gax::options::RequestOptions as GaxRequestOptions;
28
29/// A builder for [PartitionedDmlTransaction].
30///
31/// # Example
32/// ```
33/// # use google_cloud_spanner::client::Spanner;
34/// # use google_cloud_spanner::statement::Statement;
35/// # async fn build_transaction(spanner: Spanner) -> Result<(), google_cloud_spanner::Error> {
36///     let db_client = spanner.database_client("projects/p/instances/i/databases/d").build().await?;
37///     let transaction = db_client.partitioned_dml_transaction().build().await?;
38///     let statement = Statement::builder("UPDATE users SET active = true WHERE TRUE").build();
39///     let modified_rows = transaction.execute_update(statement).await?;
40/// #   Ok(())
41/// # }
42/// ```
43pub struct PartitionedDmlTransactionBuilder {
44    client: DatabaseClient,
45    retry_policy: Box<dyn TransactionRetryPolicy>,
46    exclude_txn_from_change_streams: bool,
47}
48
49impl PartitionedDmlTransactionBuilder {
50    pub(crate) fn new(client: DatabaseClient) -> Self {
51        Self {
52            client,
53            retry_policy: Box::new(BasicTransactionRetryPolicy::default()),
54            exclude_txn_from_change_streams: false,
55        }
56    }
57
58    /// Sets whether to exclude the transaction from change streams.
59    ///
60    /// # Example
61    /// ```
62    /// # use google_cloud_spanner::client::Spanner;
63    /// # async fn build_transaction(spanner: Spanner) -> Result<(), google_cloud_spanner::Error> {
64    ///     let db_client = spanner.database_client("projects/p/instances/i/databases/d").build().await?;
65    ///     let transaction = db_client
66    ///         .partitioned_dml_transaction()
67    ///         .with_exclude_txn_from_change_streams(true)
68    ///         .build()
69    ///         .await?;
70    /// #   Ok(())
71    /// # }
72    /// ```
73    ///
74    /// When set to `true`, it prevents modifications from this transaction from being tracked in change streams.
75    /// Note that this only affects change streams that have been created with the DDL option `allow_txn_exclusion = true`.
76    /// If `allow_txn_exclusion` is not set or set to `false` for a change stream, updates made within this transaction
77    /// are recorded in that change stream regardless of this setting.
78    ///
79    /// When set to `false` or not specified, modifications from this transaction are recorded in all change streams
80    /// tracking columns modified by this transaction.
81    pub fn with_exclude_txn_from_change_streams(mut self, exclude: bool) -> Self {
82        self.exclude_txn_from_change_streams = exclude;
83        self
84    }
85
86    /// Sets the retry policy for the transaction.
87    ///
88    /// # Example
89    /// ```
90    /// # use std::time::Duration;
91    /// # use google_cloud_spanner::client::Spanner;
92    /// # use google_cloud_spanner::transaction::BasicTransactionRetryPolicy;
93    /// # async fn build_transaction(spanner: Spanner) -> Result<(), google_cloud_spanner::Error> {
94    ///     let db_client = spanner.database_client("projects/p/instances/i/databases/d").build().await?;
95    ///     
96    ///     let retry_policy = BasicTransactionRetryPolicy::new()
97    ///         .with_max_attempts(5)
98    ///         .with_total_timeout(Duration::from_secs(60));
99    ///
100    ///     let transaction = db_client
101    ///         .partitioned_dml_transaction()
102    ///         .with_retry_policy(retry_policy)
103    ///         .build()
104    ///         .await?;
105    /// #   Ok(())
106    /// # }
107    /// ```
108    ///
109    /// The client will retry the entire transaction if it is aborted by Spanner.
110    /// This policy can be used to customize whether a transaction should be retried
111    /// or not. The default is to retry indefinitely until the transaction succeeds.
112    pub fn with_retry_policy<P: TransactionRetryPolicy + 'static>(mut self, policy: P) -> Self {
113        self.retry_policy = Box::new(policy);
114        self
115    }
116
117    /// Builds the [PartitionedDmlTransaction].
118    pub async fn build(self) -> crate::Result<PartitionedDmlTransaction> {
119        Ok(PartitionedDmlTransaction {
120            client: self.client,
121            retry_policy: self.retry_policy,
122            exclude_txn_from_change_streams: self.exclude_txn_from_change_streams,
123        })
124    }
125}
126
127/// A Partitioned DML transaction.
128///
129/// Partitioned DML transactions are used to execute a single DML statement that may modify a large
130/// number of rows. The execution of the statement will automatically be partitioned into smaller
131/// transactions by Spanner, which may execute in parallel.
132///
133/// A Partitioned DML transaction cannot be committed or rolled back.
134///
135/// See also: <https://docs.cloud.google.com/spanner/docs/dml-partitioned>
136pub struct PartitionedDmlTransaction {
137    client: DatabaseClient,
138    retry_policy: Box<dyn TransactionRetryPolicy>,
139    exclude_txn_from_change_streams: bool,
140}
141
142impl PartitionedDmlTransaction {
143    /// Executes a Partitioned DML statement.
144    ///
145    /// # Example
146    /// ```
147    /// # use google_cloud_spanner::client::Spanner;
148    /// # use google_cloud_spanner::statement::Statement;
149    /// # async fn run(spanner: Spanner) -> Result<(), google_cloud_spanner::Error> {
150    /// let db_client = spanner.database_client("projects/p/instances/i/databases/d").build().await?;
151    /// let transaction = db_client.partitioned_dml_transaction().build().await?;
152    /// let statement = Statement::builder("UPDATE users SET active = true WHERE TRUE").build();
153    /// let modified_rows = transaction.execute_update(statement).await?;
154    /// # Ok(())
155    /// # }
156    /// ```
157    ///
158    /// # Return
159    ///
160    /// The number of rows that was at least modified by the statement. Note that the actual number
161    /// of rows that was modified may be higher than this number if the statement was retried or
162    /// split into multiple transactions by Spanner, and some of these (sub)transactions were
163    /// executed multiple times.
164    ///
165    /// See also: <https://docs.cloud.google.com/spanner/docs/dml-partitioned>
166    pub async fn execute_update<T: Into<Statement>>(self, statement: T) -> crate::Result<i64> {
167        let statement = statement.into();
168        let mut gax_options = statement.gax_options().clone();
169        self.amend_gax_options(&mut gax_options);
170
171        let session_name = self.client.session_name();
172        let transaction_options = TransactionOptions::default()
173            .set_partitioned_dml(PartitionedDml::default())
174            .set_exclude_txn_from_change_streams(self.exclude_txn_from_change_streams);
175        let begin_request = BeginTransactionRequest {
176            session: session_name.clone(),
177            options: Some(transaction_options),
178            ..Default::default()
179        };
180        let base_request = statement.into_request();
181        let channel_hint = self.client.spanner.next_channel_hint();
182        let client = self.client;
183        let is_emulator = client.is_emulator();
184
185        let action = || {
186            let begin_request = begin_request.clone();
187            let base_request = base_request.clone();
188            let session_name = session_name.clone();
189            let gax_options = gax_options.clone();
190            let client = client.clone();
191
192            async move {
193                let transaction = client
194                    .spanner
195                    .begin_transaction(
196                        begin_request,
197                        gax_options.clone(),
198                        channel_hint,
199                        &client.o11y,
200                    )
201                    .await?;
202
203                let execute_request =
204                    base_request
205                        .set_session(session_name)
206                        .set_transaction(TransactionSelector {
207                            selector: Some(transaction_selector::Selector::Id(
208                                transaction.id.clone(),
209                            )),
210                            ..Default::default()
211                        });
212
213                let stream_builder = client.spanner.execute_streaming_sql(
214                    execute_request,
215                    gax_options,
216                    channel_hint,
217                );
218                let stream = stream_builder.send().await?;
219
220                extract_lower_bound_update_count_from_stream(stream).await
221            }
222        };
223
224        retry_aborted(&*self.retry_policy, action, is_emulator).await
225    }
226
227    fn amend_gax_options(&self, options: &mut GaxRequestOptions) {
228        *options = amend_request_options_for_lar(
229            self.client.leader_aware_routing_enabled,
230            options.clone(),
231        );
232    }
233}
234
235/// Reads through the stream of `PartialResultSet` messages returned by the execution
236/// of a Partitioned DML statement and extracts the `row_count_lower_bound` from the
237/// query statistics. If the execution is successful but no lower bound is found,
238/// an internal error is returned.
239async fn extract_lower_bound_update_count_from_stream(
240    mut stream: PartialResultSetStream,
241) -> crate::Result<i64> {
242    let mut lower_bound: Option<i64> = None;
243    while let Some(prs) = stream.next_message().await.transpose()? {
244        if let Some(RowCountLowerBound(val)) = prs.stats.and_then(|s| s.row_count) {
245            lower_bound = Some(val);
246        }
247    }
248    lower_bound.ok_or_else(|| {
249        crate::Error::deser(crate::error::SpannerInternalError::new(
250            "ExecuteStreamingSql completed successfully but no row_count_lower_bound was returned",
251        ))
252    })
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::read_only_transaction::tests::{create_session_mock, setup_db_client};
259    use crate::result_set::tests::adapt;
260    use crate::transaction_retry_policy::tests::create_aborted_status;
261    use gaxi::grpc::tonic;
262    use google_cloud_test_macros::tokio_test_no_panics;
263    use spanner_grpc_mock::google::spanner::v1;
264
265    #[test]
266    fn auto_traits() {
267        static_assertions::assert_impl_all!(PartitionedDmlTransactionBuilder: Send, Sync);
268        static_assertions::assert_impl_all!(PartitionedDmlTransaction: Send, Sync);
269    }
270
271    #[tokio_test_no_panics]
272    async fn execute_update_success() {
273        let mut mock = create_session_mock();
274
275        mock.expect_begin_transaction().once().returning(|req| {
276            let req = req.into_inner();
277            assert_eq!(
278                req.session,
279                "projects/p/instances/i/databases/d/sessions/123"
280            );
281            Ok(tonic::Response::new(v1::Transaction {
282                id: vec![0, 1, 2],
283                ..Default::default()
284            }))
285        });
286
287        mock.expect_execute_streaming_sql().once().returning(|req| {
288            let req = req.into_inner();
289            assert_eq!(req.sql, "UPDATE Users SET active = true");
290
291            let stream = adapt([Ok(v1::PartialResultSet {
292                stats: Some(v1::ResultSetStats {
293                    row_count: Some(v1::result_set_stats::RowCount::RowCountLowerBound(500)),
294                    ..Default::default()
295                }),
296                ..Default::default()
297            })]);
298            Ok(tonic::Response::from(stream))
299        });
300
301        let (db_client, _server) = setup_db_client(mock).await;
302        let transaction = db_client
303            .partitioned_dml_transaction()
304            .build()
305            .await
306            .unwrap();
307        let statement = Statement::builder("UPDATE Users SET active = true").build();
308        let res: i64 = transaction.execute_update(statement).await.unwrap();
309        assert_eq!(res, 500);
310    }
311
312    #[tokio_test_no_panics]
313    async fn execute_update_with_exclude_txn_from_change_streams() {
314        let mut mock = create_session_mock();
315
316        mock.expect_begin_transaction().once().returning(|req| {
317            let req = req.into_inner();
318            let options = req.options.expect("missing transaction options");
319            assert!(options.exclude_txn_from_change_streams);
320
321            Ok(tonic::Response::new(v1::Transaction {
322                id: vec![0, 1, 2],
323                ..Default::default()
324            }))
325        });
326
327        mock.expect_execute_streaming_sql()
328            .once()
329            .returning(|_req| {
330                let stream = adapt([Ok(v1::PartialResultSet {
331                    stats: Some(v1::ResultSetStats {
332                        row_count: Some(v1::result_set_stats::RowCount::RowCountLowerBound(500)),
333                        ..Default::default()
334                    }),
335                    ..Default::default()
336                })]);
337                Ok(tonic::Response::from(stream))
338            });
339
340        let (db_client, _server) = setup_db_client(mock).await;
341        let transaction = db_client
342            .partitioned_dml_transaction()
343            .with_exclude_txn_from_change_streams(true)
344            .build()
345            .await
346            .unwrap();
347        let statement = Statement::builder("UPDATE Users SET active = true").build();
348        let res: i64 = transaction.execute_update(statement).await.unwrap();
349        assert_eq!(res, 500);
350    }
351
352    #[tokio_test_no_panics]
353    async fn execute_update_with_aborted_retry() {
354        let mut mock = create_session_mock();
355
356        mock.expect_begin_transaction().times(2).returning(|_req| {
357            Ok(tonic::Response::new(v1::Transaction {
358                id: vec![0, 1, 2],
359                ..Default::default()
360            }))
361        });
362
363        let mut seq = mockall::Sequence::new();
364        mock.expect_execute_streaming_sql()
365            .times(1)
366            .in_sequence(&mut seq)
367            .returning(move |_req| {
368                // Return an error stream on first try
369                let stream = adapt([Err(create_aborted_status(std::time::Duration::from_nanos(
370                    1,
371                )))]);
372                Ok(tonic::Response::from(stream))
373            });
374        mock.expect_execute_streaming_sql()
375            .times(1)
376            .in_sequence(&mut seq)
377            .returning(move |_req| {
378                let stream = adapt([Ok(v1::PartialResultSet {
379                    stats: Some(v1::ResultSetStats {
380                        row_count: Some(v1::result_set_stats::RowCount::RowCountLowerBound(100)),
381                        ..Default::default()
382                    }),
383                    ..Default::default()
384                })]);
385                Ok(tonic::Response::from(stream))
386            });
387
388        let (db_client, _server) = setup_db_client(mock).await;
389        let transaction = db_client
390            .partitioned_dml_transaction()
391            .build()
392            .await
393            .unwrap();
394        let res: i64 = transaction
395            .execute_update(Statement::builder("UPDATE Users SET active = true").build())
396            .await
397            .unwrap();
398        assert_eq!(res, 100);
399    }
400
401    #[tokio_test_no_panics]
402    async fn builder_with_retry_settings() {
403        let mock = create_session_mock();
404        let (db_client, _server) = setup_db_client(mock).await;
405
406        let policy = BasicTransactionRetryPolicy::new()
407            .with_max_attempts(10)
408            .with_total_timeout(std::time::Duration::from_secs(42));
409
410        let _transaction = db_client
411            .partitioned_dml_transaction()
412            .with_retry_policy(policy)
413            .build()
414            .await
415            .unwrap();
416    }
417
418    #[tokio_test_no_panics]
419    async fn execute_update_missing_lower_bound() {
420        let mut mock = create_session_mock();
421
422        mock.expect_begin_transaction().once().returning(|_req| {
423            Ok(tonic::Response::new(v1::Transaction {
424                id: vec![0, 1, 2],
425                ..Default::default()
426            }))
427        });
428
429        mock.expect_execute_streaming_sql()
430            .once()
431            .returning(|_req| {
432                let stream = adapt([Ok(v1::PartialResultSet {
433                    stats: Some(v1::ResultSetStats {
434                        // Provide a RowCountExact instead of RowCountLowerBound
435                        row_count: Some(v1::result_set_stats::RowCount::RowCountExact(100)),
436                        ..Default::default()
437                    }),
438                    ..Default::default()
439                })]);
440                Ok(tonic::Response::from(stream))
441            });
442
443        let (db_client, _server) = setup_db_client(mock).await;
444        let transaction = db_client
445            .partitioned_dml_transaction()
446            .build()
447            .await
448            .unwrap();
449
450        let statement = Statement::builder("UPDATE Users SET active = true").build();
451        let res = transaction.execute_update(statement).await;
452
453        assert!(res.is_err());
454        let err = res.unwrap_err();
455        assert!(err.is_deserialization());
456        assert!(
457            err.to_string()
458                .contains("no row_count_lower_bound was returned")
459        );
460    }
461
462    #[tokio_test_no_panics]
463    async fn leader_aware_routing_enabled_by_default() {
464        let mut mock = create_session_mock();
465        mock.expect_begin_transaction().once().returning(|req| {
466            assert_eq!(
467                req.metadata()
468                    .get("x-goog-spanner-route-to-leader")
469                    .expect("header required")
470                    .to_str()
471                    .unwrap(),
472                "true"
473            );
474            Ok(tonic::Response::new(v1::Transaction {
475                id: vec![0, 1, 2],
476                ..Default::default()
477            }))
478        });
479
480        mock.expect_execute_streaming_sql().once().returning(|req| {
481            assert_eq!(
482                req.metadata()
483                    .get("x-goog-spanner-route-to-leader")
484                    .expect("header required")
485                    .to_str()
486                    .unwrap(),
487                "true"
488            );
489            let stream = adapt([Ok(v1::PartialResultSet {
490                stats: Some(v1::ResultSetStats {
491                    row_count: Some(v1::result_set_stats::RowCount::RowCountLowerBound(500)),
492                    ..Default::default()
493                }),
494                ..Default::default()
495            })]);
496            Ok(tonic::Response::from(stream))
497        });
498
499        let (db_client, _server) = setup_db_client(mock).await;
500        let transaction = db_client
501            .partitioned_dml_transaction()
502            .build()
503            .await
504            .unwrap();
505        let statement = Statement::builder("UPDATE Users SET active = true").build();
506        let res: i64 = transaction.execute_update(statement).await.unwrap();
507        assert_eq!(res, 500);
508    }
509}