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    match transaction.operation.as_ref() {
52        Some(Operation::UpdateConfig(update)) => {
53            !update.field_metadata_updates.is_empty() || !update.field_metadata.is_empty()
54        }
55        Some(
56            Operation::Append(_)
57            | Operation::Delete(_)
58            | Operation::CreateIndex(_)
59            | Operation::Rewrite(_)
60            | Operation::DataReplacement(_)
61            | Operation::ReserveFragments(_)
62            | Operation::Update(_)
63            | Operation::UpdateMemWalState(_)
64            | Operation::UpdateBases(_)
65            | Operation::DataOverlay(_),
66        ) => false,
67        _ => true,
68    }
69}
70
71/// Write-boundary conversion: serialize using protobuf at the last step.
72impl From<Transaction> for pb::Transaction {
73    fn from(tx: Transaction) -> Self {
74        tx.inner
75    }
76}
77
78impl From<pb::Transaction> for Transaction {
79    fn from(pb_tx: pb::Transaction) -> Self {
80        Self { inner: pb_tx }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use prost::Message;
88
89    /// The classification that must never depend on payload size. A MemWAL
90    /// table's transactions carry mem-table state and routinely outgrow the
91    /// inline limit, so an exempt operation has to stay exempt while large --
92    /// otherwise the deletes that repair an invalid key are blocked on exactly
93    /// the tables most likely to have one.
94    #[test]
95    fn an_exempt_operation_is_classified_the_same_at_any_size() {
96        let small = pb::Transaction {
97            operation: Some(pb::transaction::Operation::Delete(
98                pb::transaction::Delete::default(),
99            )),
100            ..Default::default()
101        };
102        let mut large = small.clone();
103        large.tag = "x".repeat(4 * 1024 * 1024);
104
105        assert!(large.encoded_len() > small.encoded_len() * 100);
106        assert!(!operation_may_change_schema(&small));
107        assert!(!operation_may_change_schema(&large));
108    }
109
110    /// An overlay attaches files to existing fragments and supplies new cell
111    /// values; it carries no schema. Omitting it left a legacy nullable-key
112    /// dataset unable to commit one, which is the upgrade path this exemption
113    /// exists to keep open.
114    #[test]
115    fn a_data_overlay_is_exempt() {
116        let overlay = pb::Transaction {
117            operation: Some(pb::transaction::Operation::DataOverlay(
118                pb::transaction::DataOverlay::default(),
119            )),
120            ..Default::default()
121        };
122        assert!(!operation_may_change_schema(&overlay));
123    }
124
125    #[test]
126    fn field_metadata_updates_change_schema() {
127        let mut update = pb::transaction::UpdateConfig {
128            config_updates: Some(pb::transaction::UpdateMap::default()),
129            ..Default::default()
130        };
131        let config_only = pb::Transaction {
132            operation: Some(pb::transaction::Operation::UpdateConfig(update.clone())),
133            ..Default::default()
134        };
135        assert!(!operation_may_change_schema(&config_only));
136
137        update
138            .field_metadata_updates
139            .insert(0, pb::transaction::UpdateMap::default());
140        let field_update = pb::Transaction {
141            operation: Some(pb::transaction::Operation::UpdateConfig(update)),
142            ..Default::default()
143        };
144        assert!(operation_may_change_schema(&field_update));
145    }
146
147    /// And the converse, so the exemption cannot silently widen to everything.
148    #[test]
149    fn a_schema_carrying_operation_is_never_exempt() {
150        let overwrite = pb::Transaction {
151            operation: Some(pb::transaction::Operation::Overwrite(
152                pb::transaction::Overwrite::default(),
153            )),
154            ..Default::default()
155        };
156        assert!(operation_may_change_schema(&overwrite));
157
158        // An operation this build does not recognise must not be exempt
159        // either: an unknown write is not a safe one to skip.
160        let unknown = pb::Transaction::default();
161        assert!(operation_may_change_schema(&unknown));
162    }
163}