alter_table: Support adding columns to tables#30470
Conversation
b1ba847 to
6edd439
Compare
MitigationsCompleting required mitigations increases Resilience Coverage.
Risk Summary:The risk score for this pull request is high at 80, driven by predictors such as the sum of bug reports of files and the delta of executable lines. Historically, pull requests with these predictors are 114% more likely to cause a bug compared to the repository baseline. Additionally, the repository's observed and predicted bug trends are both decreasing, which is a positive sign. Note: The risk score is not based on semantic analysis but on historical predictors of bug occurrence in the repository. The attributes above were deemed the strongest predictors based on that history. Predictors and the score may change as the PR evolves in code, time, and review activity. Bug Hotspots:
|
def-
left a comment
There was a problem hiding this comment.
I very much appreciate all the tests!
What happens if you call ALTER TABLE ADD COLUMN on a continual task/table from source/mv/...? What if you try to add a column with the name of the table? Can you keep adding columns or do things get slower with number of columns? Could add some tests checking for correct errors in the SLT.
| .map(|id| self.get_entry_by_global_id(id)) | ||
| .filter_map(|entry| entry.index().map(|index| index.on)); |
There was a problem hiding this comment.
I think I might be missing something, what was the change here?
There was a problem hiding this comment.
This was just some Rust lifetime shenanigans. .index() returns an Option<&Index> but .get_entry_by_global_id(...) returns an owned type that only lives for the duration of the .map(...) call
| storage_collection_metadata: TableTransaction::new_with_uniqueness_fn( | ||
| storage_collection_metadata, | ||
| |a: &StorageCollectionMetadataValue, b| a.shard == b.shard, | ||
| )?, |
There was a problem hiding this comment.
Just confirming, now we can have multiple global IDs for the same object that all point to the same shard?
| .unwrap_or_else(|| panic!("catalog out of sync, missing id {id:?}")); | ||
| self.get_entry(item_id) | ||
|
|
||
| let entry = self.get_entry(item_id).clone(); |
There was a problem hiding this comment.
It feels a little bad to clone the entry in this function. This used to be pretty cheap but now involves cloning potentially large expressions and create sql statements.
There was a problem hiding this comment.
I totally agree, it's a bit tricky with Rust lifetimes and the trait CatalogItem, I'll circle back and see if I can improve this though. There might be a Cow<...> like thing we can do
| impl From<RelationVersion> for SchemaId { | ||
| fn from(value: RelationVersion) -> Self { | ||
| SchemaId(usize::cast_from(value.0)) | ||
| } | ||
| } |
There was a problem hiding this comment.
I don't think I understand this, what's the correlation b/w a relation version and a schema version?
There was a problem hiding this comment.
Right now RelationVersions are 1:1 with SchemaIds. At some point we can break this relationship and store the mapping somewhere in the Catalog, but it's not necessary at the moment.
| fn latest_version(&self) -> Option<RelationVersion> { | ||
| self.entry.latest_version() |
There was a problem hiding this comment.
I'm surprised that this returns an Option, when would an entry ever not have a version?
There was a problem hiding this comment.
An entry only has a version if it's version-able, i.e. only Tables will return Some here.
| let is_versioned = c | ||
| .options | ||
| .iter() | ||
| .any(|o| matches!(o.option, ColumnOption::Versioned { .. })); | ||
| !is_versioned |
There was a problem hiding this comment.
Why are we filtering here?
There was a problem hiding this comment.
Added a comment, but it's because of how the names collection is used, I took a note for myself to refactor this entire block
6edd439 to
02b0137
Compare
| let since_handle = { | ||
| // If the collection we're openning is versioned, be sure to use a | ||
| // different CriticalId so the SinceHandles don't conflict. | ||
| let reader_id = match version { |
There was a problem hiding this comment.
It's still not clear to me that we're managing the lifecycle of this handle properly... for example, I think the finalization task will only force-downgrade the handle of the controller-global handle, not these per-version handles. (And at a first pass it's not clear to me how all N critical handles get updated when the write frontier advances...)
|
Still iterating a bit, but the most recent commit removes the need for multiple At a high level the implementation is there, but pushed up the commits to run against CI |
1b30ffa to
7c3e244
Compare
f18f1b3 to
8e3af36
Compare
4a9de3d to
7ecf800
Compare
| // capability of the existing collection. This would cause runtime panics because it | ||
| // would eventually result in negative read capabilities. | ||
| let mut changes = ChangeBatch::new(); | ||
| for (time, diff) in existing_read_capabilities.updates() { |
There was a problem hiding this comment.
I believe this has to be existing_read_capabilities.frontier(): the "old table" might maintain a bunch of outstanding read holds, but we only need to install the frontier of what it needs at the new primary.
When the frontier of the view table's read holds changes, it will then go and downgrade this one read hold it holds on the primary table. I have a feeling this is the source of the panic you're seeing.
5557ec7 to
8ddd280
Compare
| match schema_result { | ||
| CaESchema::Ok(id) => id, | ||
| // TODO(alter_table): Better handling of these errors. | ||
| CaESchema::ExpectedMismatch { .. } => { |
There was a problem hiding this comment.
How can this happen? Also, in this case shouldn't we check if the actual schema is the one we wanted and if so continue?
There was a problem hiding this comment.
This shouldn't ever happen because the Coordinator should be the only one evolving the schema of this shard. I added a soft assertion to see if it ever does get hit, and a TODO to follow up. Right now this would just fail the ALTER TABLE command and the user would have to re-run it on their own.
| ))) | ||
| } | ||
| CaESchema::Incompatible => { | ||
| return Err(StorageError::Generic(anyhow::anyhow!( |
There was a problem hiding this comment.
We should add a soft_panic_or_log here since it almost certainly a bug if we hit this case, right?
There was a problem hiding this comment.
Good idea, added!
| .await; | ||
|
|
||
| // TODO(alter_table): Do we need to advance the since of the table to match the time this | ||
| // new version was registered with txn-wal? |
There was a problem hiding this comment.
Where is the txn wal involved here? Does compare and evolve schema write something there? Also, who knows the answer to this question?
There was a problem hiding this comment.
I left this TODO because when creating a table we advance the since to the register_ts. I don't think it's necessary here but I'll follow up with @aljoscha or @jkosh44
| // Because this is a Table, we know it's managed by txn_wal, and thus it's logical write | ||
| // frontier is possibly in advance of the write_handle's upper. So we fast forward the | ||
| // write frontier to match that of the existing collection. | ||
| let write_frontier = |
There was a problem hiding this comment.
The comment seems to imply that write_handle.upper() <= existing_writer_frontier is an invariant. Should we unconditionally use the existing_write_frontier then? Is there any case where that inequality doesn't hold?
There was a problem hiding this comment.
I think the only case this inequality doesn't hold is if a shard that is registered with txn-wal is written to outside of txn-wal, so misuse. I added a soft_assert here incase there is a separate edge case, e.g. on initialization or something.
| if !dependency_read_holds.is_empty() { | ||
| // | ||
| // TODO(parkmycar): Include Tables (is_in_txns) in this check. | ||
| if !dependency_read_holds.is_empty() && !is_in_txns(id, &metadata) { |
There was a problem hiding this comment.
Why is checking if the collection is in txn needed here?
There was a problem hiding this comment.
Expanded this comment!
* changes Table to use a VersionedRelationDesc * adds CatalogCollectionEntry and move desc(...) method to it * renamed CatalogEntry::desc to CatalogEntry::desc_latest
* implement sequencing in the adapter
* updates to the storage controller
* when adding a new version of a table, add the new version as a dependency for the old version
* this allows us to accurately track ReadHolds for tables and the underlying Persist Shard
* add a good number of test cases to alter-table.slt * delete duplicate table_alter.slt * add a platform-check for ALTER TABLE * add a (disabled) parallel-workload case for ALTER TABLE * add a legacy upgrade test for ALTER TABLE
* acquire a read hold on the 'original' collection while the alter is running * update tests for v0.130
* add comments explaining TODOs * add soft assertions for conditions that we shouldn't hit
a7728e8 to
4892cc6
Compare
c96f739 to
1a95918
Compare
1a95918 to
ff8f4a3
Compare
The `Op::AlterAddColumn` handler was missing an audit log entry, unlike every other ALTER operation. This adds an `AlterAddColumnV1` event that records the table ID, column name, column type, and nullability. Introduced in MaterializeInc#30470. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `Op::AlterAddColumn` handler was missing an audit log entry, unlike every other ALTER operation. This adds an `AlterAddColumnV1` event that records the table ID, column name, column type, and nullability. Introduced in MaterializeInc#30470. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The `Op::AlterAddColumn` handler was missing an audit log entry, unlike every other ALTER operation. This adds an `AlterAddColumnV1` event that records the table ID, column name, column type, and nullability. Introduced in MaterializeInc#30470. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ALTER SOURCE ... SET (TIMESTAMP INTERVAL)` (#36449) The `Op::AlterAddColumn` handler was missing an audit log entry, unlike every other ALTER operation. This adds an `AlterAddColumnV1` event that records the table ID, column name, column type, and nullability. Introduced in #30470. Nightly: https://buildkite.com/materialize/nightly/builds/16495 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ALTER SOURCE ... SET (TIMESTAMP INTERVAL)` (MaterializeInc#36449) The `Op::AlterAddColumn` handler was missing an audit log entry, unlike every other ALTER operation. This adds an `AlterAddColumnV1` event that records the table ID, column name, column type, and nullability. Introduced in MaterializeInc#30470. Nightly: https://buildkite.com/materialize/nightly/builds/16495 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ALTER SOURCE ... SET (TIMESTAMP INTERVAL)` (MaterializeInc#36449) The `Op::AlterAddColumn` handler was missing an audit log entry, unlike every other ALTER operation. This adds an `AlterAddColumnV1` event that records the table ID, column name, column type, and nullability. Introduced in MaterializeInc#30470. Nightly: https://buildkite.com/materialize/nightly/builds/16495 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This PR implements the SQL feature
ALTER TABLE ... ADD COLUMN ....Note: There are a lot of lines changed but the majority are new tests!
Specifically it:
VersionedRelationDesconTables to track new columnsCatalogCollectionEntrywhich adds some typing around getting the currentRelationDescfor an entry.storage-controllerto create new PersistWriteHandles and pass them to theTxnsTableWorkerOtherwise it also adds several tests:
alter-table.sltwhich exercises a number of different scenariosCheckfor theplatform-checks test frameworkActionfor theparallel-workloadtest framework.Motivation
Fixes https://github.com/MaterializeInc/database-issues/issues/8233
Tips for reviewer
I split the PR up into separate commits to ideally make it easier to review, most the changes here are new tests!
Checklist
$T ⇔ Proto$Tmapping (possibly in a backwards-incompatible way), then it is tagged with aT-protolabel.