Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/big-cluster/src/ddl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ impl<P: PagerMut + Sync> Cluster<P> {
self.ddl(&Ddl::TruncateTable { table: table.to_string() })
}

/// `ALTER TABLE ... RENAME TO`: the table keeps everything but its name, everywhere.
///
/// `Ok(false)` means there was no such table at the leader. It has to reach every node for
/// the reason a `TRUNCATE` does and then some: a node still holding the old name answers a
/// statement written against the new one with "unknown table", and a node holding both
/// would answer two different questions with one table's data.
pub fn rename_table(&self, table: &str, to: &str) -> Result<bool> {
self.ddl(&Ddl::RenameTable { table: table.to_string(), to: to.to_string() }).map(|n| n == 1)
}

/// `EXCHANGE TABLES a AND b`: the two names swap everywhere, or the change is partial.
///
/// `Ok(false)` means one of them was not there at the leader, and then nothing moved on any
/// node - the leader refuses before the fan-out begins, which is the whole reason the
/// decision is made in one place.
pub fn exchange_tables(&self, a: &str, b: &str) -> Result<bool> {
self.ddl(&Ddl::ExchangeTables { a: a.to_string(), b: b.to_string() }).map(|n| n == 1)
}

/// Retention, cluster-wide: drop a time-quantum field's index before an instant.
///
/// **This is the spelling `big-bin`'s offline tool said belonged with the other DDL.** Its
Expand Down Expand Up @@ -313,6 +332,12 @@ pub fn apply_ddl<P: PagerMut + Sync>(api: &Api<P>, op: &Ddl) -> big_embed::Resul
// leader has already ruled on whether it exists, so a peer without it is a peer that
// never had the data this is removing.
Ddl::TruncateTable { table } => api.truncate_table(table)?.unwrap_or(0),
// A table that is not here counts as nothing renamed, for the reason a truncate does:
// the leader has already ruled on whether it exists, and a peer without it has no name
// to change. The same holds for an exchange, where a peer missing either table has
// neither name to swap.
Ddl::RenameTable { table, to } => u64::from(api.rename_table(table, to)?),
Ddl::ExchangeTables { a, b } => u64::from(api.exchange_tables(a, b)?),
Ddl::DropViewsBefore { table, field, unix_seconds } => {
api.drop_days_before(table, field, *unix_seconds)? as u64
}
Expand Down
50 changes: 50 additions & 0 deletions crates/big-cluster/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ impl<P: PagerMut + Sync> Cluster<P> {
big_embed::SqlDdl::TruncateTable { database, table, if_exists } => {
("fragments", self.sql_truncate_table(&qualified(database, table), *if_exists)?)
}
// The new name is qualified here rather than at the parser, which took it bare: the
// database a rename lands in is the one the table is already in, and that is a fact
// about this statement's own table ref rather than about the word that was typed.
big_embed::SqlDdl::RenameTable { database, table, to } => (
"renamed",
self.sql_rename_table(&qualified(database, table), &qualified(database, to))?,
),
big_embed::SqlDdl::ExchangeTables { database, a, b } => (
"exchanged",
self.sql_exchange_tables(&qualified(database, a), &qualified(database, b))?,
),
big_embed::SqlDdl::CreateView { database, name, body, or_replace, if_not_exists } => (
"view",
self.create_view(&qualified(database, name), body, *or_replace, *if_not_exists)?
Expand Down Expand Up @@ -661,6 +672,41 @@ impl<P: PagerMut + Sync> Cluster<P> {
self.truncate_table(table)
}

/// `ALTER TABLE ... RENAME TO`, answering with 1.
///
/// Both halves are judged here, before the leader is asked, because both are questions the
/// schema already answers and a fan-out that failed half way through would answer neither:
/// a table that is not there is `UnknownTable`, and a new name somebody already holds is
/// refused rather than silently overwriting them.
fn sql_rename_table(&self, table: &str, to: &str) -> Result<u64> {
let schema = self.schema();
if !schema.iter().any(|t| t.name == table) {
return Err(local(big_db::DbError::UnknownTable(table.to_string())));
}
// A rename onto the name it already has is the change it describes: none. It is let
// through rather than short-circuited, so the count a client sees does not depend on
// whether they happened to pick the current name.
if table != to && schema.iter().any(|t| t.name == to) {
return Err(local(big_db::DbError::NameTaken(to.to_string())));
}
self.rename_table(table, to).map(u64::from)
}

/// `EXCHANGE TABLES a AND b`, answering with 1.
///
/// Both tables have to exist, and the failure names the one that does not: an exchange
/// against a typo would otherwise report "nothing exchanged", which reads exactly like a
/// swap that happened to be a no-op.
fn sql_exchange_tables(&self, a: &str, b: &str) -> Result<u64> {
let schema = self.schema();
for name in [a, b] {
if !schema.iter().any(|t| t.name == name) {
return Err(local(big_db::DbError::UnknownTable(name.to_string())));
}
}
self.exchange_tables(a, b).map(u64::from)
}

/// `DELETE FROM t WHERE ...`: select across every owner, then clear.
///
/// **Two steps, and the order is the whole of the design.** The predicate is planned, fanned
Expand Down Expand Up @@ -1089,6 +1135,10 @@ impl<P: PagerMut + Sync> Cluster<P> {
}))
}
big_embed::SqlShown::Roles => Ok(big_embed::introspect::show_roles(&self.api.roles())),
// Reads nothing - not this node's catalog, not another node's. Which is why it is
// answered here beside the listings rather than fanned out: there is no share of it
// any owner could hold.
big_embed::SqlShown::Numbers { n } => Ok(big_embed::introspect::numbers(*n)),
// A bare `SHOW GRANTS` is about the caller's own role, which is why it needs no
// privilege: reading what you hold tells you nothing you could not find out by
// trying. A `Trusted` caller holds everything and has no role to name, so it gets
Expand Down
32 changes: 32 additions & 0 deletions crates/big-cluster/src/wire/fact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,26 @@ pub enum Ddl {
field: String,
unix_seconds: i64,
},
/// `ALTER TABLE ... RENAME TO`: one catalog record, no data.
///
/// Both names qualified, and the database in them is the same one - a rename that moved a
/// table is not a change this carries. The **new name travels rather than being derived**,
/// for the reason [`Ddl::DropViewsBefore`] sends an instant rather than a day name: two
/// nodes deriving it separately is two chances to derive it differently.
RenameTable {
table: String,
to: String,
},
/// `EXCHANGE TABLES a AND b`: the two names swap which table they resolve to.
///
/// A variant of its own rather than two renames, and for the reason [`Ddl::TruncateTable`]
/// is not a drop and a create: two renames need a name nobody holds to pass through, and a
/// peer that applied one of them and not the other would disagree with every other node
/// about what both names mean. One operation everywhere, or it is not the same operation.
ExchangeTables {
a: String,
b: String,
},
CreateDatabase {
name: String,
},
Expand Down Expand Up @@ -326,6 +346,16 @@ impl Ddl {
put_u8(&mut out, 13);
put_str(&mut out, table);
}
Self::RenameTable { table, to } => {
put_u8(&mut out, 15);
put_str(&mut out, table);
put_str(&mut out, to);
}
Self::ExchangeTables { a, b } => {
put_u8(&mut out, 16);
put_str(&mut out, a);
put_str(&mut out, b);
}
Self::DropTable { table } => {
put_u8(&mut out, 4);
put_str(&mut out, table);
Expand Down Expand Up @@ -422,6 +452,8 @@ impl Ddl {
10 => Self::CreateRole { role: r.str()? },
11 => Self::DropRole { role: r.str()? },
13 => Self::TruncateTable { table: r.str()? },
15 => Self::RenameTable { table: r.str()?, to: r.str()? },
16 => Self::ExchangeTables { a: r.str()?, b: r.str()? },
14 => Self::DropViewsBefore {
table: r.str()?,
field: r.str()?,
Expand Down
80 changes: 80 additions & 0 deletions crates/big-cluster/tests/logic/schema.test
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,83 @@ error
ALTER TABLE visits DROP DAYS BEFORE '2026-01-02' ON nope
----
error: unknown_field

## `RENAME TO` and `EXCHANGE TABLES`: catalog records, against real records.
#
# The claim worth running here rather than only in translation is that the *data* follows the
# table and not the name. Both statements move an interned id between two names and rewrite
# nothing below the catalog, so what a name resolves to afterwards is the only thing that
# changed - and that is not visible until there are facts on both sides to tell apart.

statement
CREATE TABLE live (amount UINT(32))

statement
INSERT INTO live (_record_id, amount) VALUES (1, 10), (2, 20)

statement
CREATE TABLE rebuilt (amount UINT(32))

statement
INSERT INTO rebuilt (_record_id, amount) VALUES (1, 700)

query
SELECT sum(amount) FROM live
----
sum
30

# One change, and afterwards each name reads the other table's facts.
statement
EXCHANGE TABLES live AND rebuilt

query
SELECT sum(amount) FROM live
----
sum
700

query
SELECT sum(amount) FROM rebuilt
----
sum
30

# Running it again is the undo - which is what makes it safe to reach for during a rebuild.
statement
EXCHANGE TABLES live AND rebuilt

query
SELECT sum(amount) FROM live
----
sum
30

# A rename keeps everything but the name, including the facts and the fields.
statement
ALTER TABLE rebuilt RENAME TO archive

query
SELECT sum(amount) FROM archive
----
sum
700

error
SELECT sum(amount) FROM rebuilt
----
error: unknown_table

# Renaming onto a name somebody already holds would leave one of the two unreachable, so it is
# refused rather than overwriting them.
error
ALTER TABLE archive RENAME TO live
----
error: name_taken

# An exchange against a name that is not there is `unknown_table` and not "nothing exchanged",
# which would read exactly like a swap that happened to be a no-op.
error
EXCHANGE TABLES live AND typo
----
error: unknown_table
26 changes: 26 additions & 0 deletions crates/big-cluster/tests/logic/system.test
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,29 @@ error
SELECT * FROM system.tables ORDER BY name
----
error: sql_system_clause

## `numbers(n)`: rows from nothing.
#
# Worth running against a live node rather than only in translation, because the claim is that
# it needs no table, no fact and no plan - which is only visible when something actually answers.

query
SELECT * FROM numbers(4)
----
number
0
1
2
3

query
SELECT number FROM numbers(1)
----
number
0

# Zero rows is an answer, with the header still there.
query
SELECT * FROM numbers(0)
----
number
30 changes: 30 additions & 0 deletions crates/big-cluster/tests/logic/windows.test
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,33 @@ SELECT amount, row_number() OVER (ORDER BY amount DESC) FROM tx LIMIT 2
amount row_number
100 6
900 1

## `runningDifference(x)`: the difference against the previous row of the partition.
#
# Worth running here rather than only in translation because the claim is about the *partition*
# being whole: the previous row is the previous row of every shard's contribution merged, not of
# the share one node happened to hold. A per-node difference would restart at each shard, and the
# first row of each would be blank instead of just the first row overall.

query
SELECT amount, runningDifference(amount) OVER (ORDER BY amount) FROM tx
----
amount runningDifference
100 NULL
900 200
500 200
700 200
200 100
300 100

# Partitioned, so each partition's first row is the blank one.
query
SELECT country, amount, runningDifference(amount) OVER (PARTITION BY country ORDER BY amount) FROM tx
----
country amount runningDifference
GB 100 NULL
US 900 700
GB 500 200
FR 700 NULL
US 200 NULL
GB 300 200
35 changes: 35 additions & 0 deletions crates/big-db/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,41 @@ impl Catalog {
Ok(true)
}

/// Swaps which table each of two names resolves to.
///
/// `Ok(false)` means one of them was not there, and then nothing moved: a swap that renamed
/// half of itself would leave one name pointing at data the other used to hold.
///
/// **Not three [`Self::rename_table`] calls through a temporary name**, which is the obvious
/// way to write it and the wrong one. A temporary name is a name that may already have an
/// owner, and it is three edits with two moments in between where a name resolves somewhere
/// nobody asked for. This is two `insert`s over ids already read, so no intermediate state
/// exists to be observed or to be left behind by a failure.
///
/// Nothing below the catalog moves, for the reason a rename moves nothing: fragments, row
/// keys, field ids and grants are all keyed by [`TableId`], and an id is exactly what is
/// staying put here. A name is the only thing either table had that the other now has.
pub fn exchange_tables(&mut self, database: DatabaseId, a: &str, b: &str) -> Result<bool> {
let by_name = self.table_ids.entry(database).or_default();
let (Some(&ia), Some(&ib)) = (by_name.get(a), by_name.get(b)) else {
return Ok(false);
};
// Exchanging a table with itself is the change it describes: none. Answered as done
// rather than refused, because the statement is satisfiable and already satisfied.
if ia == ib {
return Ok(true);
}
by_name.insert(a.to_string(), ib);
by_name.insert(b.to_string(), ia);
if let Some(t) = self.tables.get_mut(&ia) {
t.name = b.to_string();
}
if let Some(t) = self.tables.get_mut(&ib) {
t.name = a.to_string();
}
Ok(true)
}

/// Removes a table, its fields, its row keys and its fragment metadata.
///
/// Returns every `FragmentKey` that was registered under it, because those are exactly the
Expand Down
Loading
Loading