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}