Skip to main content

lance_table/format/
transaction.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Transaction struct for lance-table format layer.
5//!
6//! This struct is introduced to provide a Struct-first API for passing transaction
7//! information within the lance-table crate. It mirrors the protobuf Transaction
8//! message at a semantic level while remaining crate-local, so lance-table does
9//! not depend on higher layers (e.g., lance crate).
10//!
11//! Conversion to protobuf occurs at the write boundary. See the `From<Transaction>`
12//! implementation below.
13
14use crate::format::pb;
15
16#[derive(Clone, Debug, PartialEq)]
17pub struct Transaction {
18    /// Crate-local representation backing: protobuf Transaction.
19    /// Keeping this simple avoids ring dependencies while still enabling
20    /// Struct-first parameter passing in lance-table.
21    pub inner: pb::Transaction,
22}
23
24impl Transaction {
25    /// Accessor for testing or internal inspection if needed.
26    pub fn as_pb(&self) -> &pb::Transaction {
27        &self.inner
28    }
29
30    /// Whether this transaction can change the schema, and so can introduce or
31    /// worsen an invalid primary key.
32    ///
33    /// The rest leave the key exactly as they found it, so a table that already
34    /// carries an invalid one stays writable through them -- including the
35    /// deletes needed to repair it. An unrecognized operation counts as
36    /// schema-changing: an unknown write is not a safe one to exempt.
37    pub fn may_change_schema(&self) -> bool {
38        operation_may_change_schema(&self.inner)
39    }
40}
41
42/// The same classification for a protobuf that has not been wrapped yet.
43///
44/// The commit path has to classify the operation before it knows whether the
45/// encoded bytes are small enough to inline into the manifest. Reading the
46/// disposition off the inline copy instead would tie it to the payload size,
47/// so the identical operation would be classified one way under the inline
48/// limit and the other way above it.
49pub fn operation_may_change_schema(transaction: &pb::Transaction) -> bool {
50    use pb::transaction::Operation;
51    !matches!(
52        transaction.operation.as_ref(),
53        Some(
54            Operation::Append(_)
55                | Operation::Delete(_)
56                | Operation::CreateIndex(_)
57                | Operation::Rewrite(_)
58                | Operation::DataReplacement(_)
59                | Operation::ReserveFragments(_)
60                | Operation::Update(_)
61                | Operation::UpdateConfig(_)
62                | Operation::UpdateMemWalState(_)
63                | Operation::UpdateBases(_)
64                | Operation::DataOverlay(_)
65        )
66    )
67}
68
69/// Write-boundary conversion: serialize using protobuf at the last step.
70impl From<Transaction> for pb::Transaction {
71    fn from(tx: Transaction) -> Self {
72        tx.inner
73    }
74}
75
76impl From<pb::Transaction> for Transaction {
77    fn from(pb_tx: pb::Transaction) -> Self {
78        Self { inner: pb_tx }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use prost::Message;
86
87    /// The classification that must never depend on payload size. A MemWAL
88    /// table's transactions carry mem-table state and routinely outgrow the
89    /// inline limit, so an exempt operation has to stay exempt while large --
90    /// otherwise the deletes that repair an invalid key are blocked on exactly
91    /// the tables most likely to have one.
92    #[test]
93    fn an_exempt_operation_is_classified_the_same_at_any_size() {
94        let small = pb::Transaction {
95            operation: Some(pb::transaction::Operation::Delete(
96                pb::transaction::Delete::default(),
97            )),
98            ..Default::default()
99        };
100        let mut large = small.clone();
101        large.tag = "x".repeat(4 * 1024 * 1024);
102
103        assert!(large.encoded_len() > small.encoded_len() * 100);
104        assert!(!operation_may_change_schema(&small));
105        assert!(!operation_may_change_schema(&large));
106    }
107
108    /// An overlay attaches files to existing fragments and supplies new cell
109    /// values; it carries no schema. Omitting it left a legacy nullable-key
110    /// dataset unable to commit one, which is the upgrade path this exemption
111    /// exists to keep open.
112    #[test]
113    fn a_data_overlay_is_exempt() {
114        let overlay = pb::Transaction {
115            operation: Some(pb::transaction::Operation::DataOverlay(
116                pb::transaction::DataOverlay::default(),
117            )),
118            ..Default::default()
119        };
120        assert!(!operation_may_change_schema(&overlay));
121    }
122
123    /// And the converse, so the exemption cannot silently widen to everything.
124    #[test]
125    fn a_schema_carrying_operation_is_never_exempt() {
126        let overwrite = pb::Transaction {
127            operation: Some(pb::transaction::Operation::Overwrite(
128                pb::transaction::Overwrite::default(),
129            )),
130            ..Default::default()
131        };
132        assert!(operation_may_change_schema(&overwrite));
133
134        // An operation this build does not recognise must not be exempt
135        // either: an unknown write is not a safe one to skip.
136        let unknown = pb::Transaction::default();
137        assert!(operation_may_change_schema(&unknown));
138    }
139}