Skip to content

Commit 91d6d5b

Browse files
Fix primary key migration causing stale schema (#3934) (#4666)
## Summary Fixes #3934. Removing or changing a `#[primary_key]` annotation succeeds on the first re-publish, but the stored schema's `table_primary_key` field in `st_table` is never updated. On the **next** publish, `check_compatible` fails with: ``` Primary key mismatch: self.primary_key: Some(ColId(0)), def.primary_key: None ``` ## Root Cause `auto_migrate_table` handles removing the PK's index and unique constraint, but there was no `AutoMigrateStep` to update the `table_primary_key` field in the system table. ## Fix - Add `AutoMigrateStep::ChangePrimaryKey` variant to the auto-migration planner - Detect `old.primary_key != new.primary_key` in `auto_migrate_table` and emit the step - Add `alter_table_primary_key` to the datastore layer (`mut_tx`, `datastore`, `relational_db`) with proper rollback support via `PendingSchemaChange::TableAlterPrimaryKey` - Handle the step in `auto_migrate_database` - Add migration plan formatter support (termcolor output) ## Repro Script ```bash # 1. Publish with primary key spacetime publish repro --yes # table has #[primary_key] on name # 2. Remove primary key — succeeds spacetime publish repro --yes # removed #[primary_key] # 3. Any change — CRASHES spacetime publish repro --yes # Primary key mismatch error ``` ## Tests - **Unit test** in `crates/core/src/db/update.rs`: Reproduces the exact three-publish sequence from the issue (create table with PK → remove PK → trivial change) - **Smoketest** in `crates/smoketests/tests/smoketests/auto_migration.rs`: Full end-to-end publish flow exercising the same scenario ## Files Changed | File | Change | |------|--------| | `crates/schema/src/auto_migrate.rs` | Add `ChangePrimaryKey` variant + detection | | `crates/schema/src/auto_migrate/formatter.rs` | Format the new step | | `crates/schema/src/auto_migrate/termcolor_formatter.rs` | Colored output | | `crates/datastore/src/locking_tx_datastore/tx_state.rs` | `TableAlterPrimaryKey` pending change | | `crates/datastore/src/locking_tx_datastore/mut_tx.rs` | `alter_table_primary_key` | | `crates/datastore/src/locking_tx_datastore/datastore.rs` | Expose through datastore | | `crates/datastore/src/locking_tx_datastore/committed_state.rs` | Rollback support | | `crates/core/src/db/relational_db.rs` | Expose through RelationalDB | | `crates/core/src/db/update.rs` | Handle step + unit test | | `crates/smoketests/.../auto_migration.rs` | Smoketest | --------- Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com>
1 parent 2b3aa5a commit 91d6d5b

11 files changed

Lines changed: 253 additions & 6 deletions

File tree

crates/core/src/db/relational_db.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,15 @@ impl RelationalDB {
989989
Ok(self.inner.alter_table_access_mut_tx(tx, name, access)?)
990990
}
991991

992+
pub(crate) fn alter_table_primary_key(
993+
&self,
994+
tx: &mut MutTx,
995+
name: &str,
996+
primary_key: Option<ColId>,
997+
) -> Result<(), DBError> {
998+
Ok(self.inner.alter_table_primary_key_mut_tx(tx, name, primary_key)?)
999+
}
1000+
9921001
pub(crate) fn alter_table_row_type(
9931002
&self,
9941003
tx: &mut MutTx,

crates/core/src/db/update.rs

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,11 @@ fn auto_migrate_database(
282282
let table_def = plan.new.stored_in_table_def(&table_name.clone().into()).unwrap();
283283
stdb.alter_table_access(tx, table_name, table_def.table_access.into())?;
284284
}
285+
spacetimedb_schema::auto_migrate::AutoMigrateStep::ChangePrimaryKey(table_name) => {
286+
let table_def = plan.new.stored_in_table_def(&table_name.clone().into()).unwrap();
287+
log!(logger, "Changing primary key for table `{table_name}`");
288+
stdb.alter_table_primary_key(tx, table_name, table_def.primary_key)?;
289+
}
285290
spacetimedb_schema::auto_migrate::AutoMigrateStep::AddSchedule(_) => {
286291
anyhow::bail!("Adding schedules is not yet implemented");
287292
}
@@ -336,7 +341,7 @@ mod test {
336341
};
337342
use spacetimedb_datastore::locking_tx_datastore::PendingSchemaChange;
338343
use spacetimedb_lib::db::raw_def::v9::{btree, RawModuleDefV9Builder, TableAccess};
339-
use spacetimedb_sats::{product, AlgebraicType::U64};
344+
use spacetimedb_sats::{product, AlgebraicType, AlgebraicType::U64};
340345
use spacetimedb_schema::{auto_migrate::ponder_migrate, def::ModuleDef};
341346

342347
struct TestLogger;
@@ -419,6 +424,77 @@ mod test {
419424
Ok(())
420425
}
421426

427+
/// Regression test for #3934: removing a primary key annotation and then
428+
/// re-publishing causes "Primary key mismatch" on the NEXT publish.
429+
#[test]
430+
fn update_db_remove_primary_key_issue_3934() -> anyhow::Result<()> {
431+
let auth_ctx = AuthCtx::for_testing();
432+
let stdb = TestDB::durable()?;
433+
434+
// Step 1: Table with a primary key (requires unique constraint + index).
435+
let module_v1 = {
436+
let mut builder = RawModuleDefV9Builder::new();
437+
builder
438+
.build_table_with_new_type("person", [("name", AlgebraicType::String)], true)
439+
.with_primary_key(0)
440+
.with_unique_constraint(0)
441+
.with_index(btree(0), "person_name_idx")
442+
.with_access(TableAccess::Public)
443+
.finish();
444+
let raw: ModuleDef = builder.finish().try_into().expect("valid module def");
445+
raw
446+
};
447+
448+
// Step 2: Same table, but primary key removed.
449+
let module_v2 = {
450+
let mut builder = RawModuleDefV9Builder::new();
451+
builder
452+
.build_table_with_new_type("person", [("name", AlgebraicType::String)], true)
453+
.with_access(TableAccess::Public)
454+
.finish();
455+
let raw: ModuleDef = builder.finish().try_into().expect("valid module def");
456+
raw
457+
};
458+
459+
// Step 3: Trivially different module (same as v2, simulates "change anything").
460+
let module_v3 = {
461+
let mut builder = RawModuleDefV9Builder::new();
462+
builder
463+
.build_table_with_new_type("person", [("name", AlgebraicType::String)], true)
464+
.with_access(TableAccess::Public)
465+
.finish();
466+
builder.add_reducer("noop", spacetimedb_sats::ProductType::unit(), None);
467+
let raw: ModuleDef = builder.finish().try_into().expect("valid module def");
468+
raw
469+
};
470+
471+
// Publish v1.
472+
let mut tx = begin_mut_tx(&stdb);
473+
for def in module_v1.tables() {
474+
create_table_from_def(&stdb, &mut tx, &module_v1, def)?;
475+
}
476+
stdb.commit_tx(tx)?;
477+
478+
// Migrate v1 → v2 (remove primary key). Should succeed.
479+
let mut tx = begin_mut_tx(&stdb);
480+
let plan = ponder_migrate(&module_v1, &module_v2)?;
481+
let res = update_database(&stdb, &mut tx, auth_ctx.clone(), plan, &TestLogger)?;
482+
assert!(matches!(res, UpdateResult::Success), "v1 → v2 migration failed");
483+
stdb.commit_tx(tx)?;
484+
485+
// Migrate v2 → v3 (trivial change). This is where #3934 crashes.
486+
let mut tx = begin_mut_tx(&stdb);
487+
let plan = ponder_migrate(&module_v2, &module_v3)?;
488+
let res = update_database(&stdb, &mut tx, auth_ctx, plan, &TestLogger)?;
489+
assert!(
490+
matches!(res, UpdateResult::Success),
491+
"v2 → v3 migration failed (issue #3934)"
492+
);
493+
stdb.commit_tx(tx)?;
494+
495+
Ok(())
496+
}
497+
422498
fn empty_module() -> ModuleDef {
423499
RawModuleDefV9Builder::new()
424500
.finish()
@@ -461,7 +537,6 @@ mod test {
461537
"removing a table should disconnect clients"
462538
);
463539
assert!(stdb.table_id_from_name_mut(&tx, "droppable")?.is_none());
464-
// `drop_table` records a `TableRemoved` schema change for the subscription system.
465540
assert!(
466541
tx.pending_schema_changes()
467542
.iter()
@@ -498,7 +573,6 @@ mod test {
498573
err.to_string().contains("table contains data"),
499574
"error should mention that the table contains data, got: {err}"
500575
);
501-
// The migration failed, so no schema changes should be pending.
502576
assert!(
503577
tx.pending_schema_changes().is_empty(),
504578
"failed migration should leave no pending schema changes: {:?}",

crates/datastore/src/locking_tx_datastore/committed_state.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,11 @@ impl CommittedState {
12951295
let table = self.tables.get_mut(&table_id)?;
12961296
table.with_mut_schema(|s| s.table_access = access);
12971297
}
1298+
// A table's primary key was changed. Change back to the old one.
1299+
TableAlterPrimaryKey(table_id, old_pk) => {
1300+
let table = self.tables.get_mut(&table_id)?;
1301+
table.with_mut_schema(|s| s.primary_key = old_pk.and_then(|cl| cl.as_singleton()));
1302+
}
12981303
// A table's row type was changed. Change back to the old one.
12991304
// The row representation of old rows hasn't changed,
13001305
// so it's safe to not rewrite the rows and merely change the type back.

crates/datastore/src/locking_tx_datastore/datastore.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use spacetimedb_durability::TxOffset;
3434
use spacetimedb_lib::{db::auth::StAccess, metrics::ExecutionMetrics};
3535
use spacetimedb_lib::{ConnectionId, Identity};
3636
use spacetimedb_paths::server::SnapshotDirPath;
37-
use spacetimedb_primitives::{ColList, ConstraintId, IndexId, SequenceId, TableId, ViewId};
37+
use spacetimedb_primitives::{ColId, ColList, ConstraintId, IndexId, SequenceId, TableId, ViewId};
3838
use spacetimedb_sats::{
3939
algebraic_value::de::ValueDeserializer, bsatn, buffer::BufReader, AlgebraicValue, ProductValue,
4040
};
@@ -330,6 +330,19 @@ impl Locking {
330330
tx.alter_table_access(table_id, access)
331331
}
332332

333+
pub fn alter_table_primary_key_mut_tx(
334+
&self,
335+
tx: &mut MutTxId,
336+
name: &str,
337+
primary_key: Option<ColId>,
338+
) -> Result<()> {
339+
let table_id = self
340+
.table_id_from_name_mut_tx(tx, name)?
341+
.ok_or_else(|| TableError::NotFound(name.into()))?;
342+
343+
tx.alter_table_primary_key(table_id, primary_key)
344+
}
345+
333346
pub fn alter_table_row_type_mut_tx(
334347
&self,
335348
tx: &mut MutTxId,

crates/datastore/src/locking_tx_datastore/mut_tx.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,26 @@ impl MutTxId {
10821082
Ok(())
10831083
}
10841084

1085+
/// Change the primary key of the table identified by `table_id`.
1086+
///
1087+
/// Updates both the in-memory schema and the `st_table` system table.
1088+
/// See: <https://github.com/clockworklabs/SpacetimeDB/issues/3934>
1089+
pub(crate) fn alter_table_primary_key(&mut self, table_id: TableId, new_primary_key: Option<ColId>) -> Result<()> {
1090+
// Write to the table in the tx state.
1091+
let ((tx_table, ..), (commit_table, ..)) = self.get_or_create_insert_table_mut(table_id)?;
1092+
tx_table.with_mut_schema_and_clone(commit_table, |s| s.primary_key = new_primary_key);
1093+
1094+
// Update system tables.
1095+
let new_pk_col_list = new_primary_key.map(|col| col.into());
1096+
let old_pk =
1097+
self.update_st_table_row(table_id, |st| mem::replace(&mut st.table_primary_key, new_pk_col_list))?;
1098+
1099+
// Remember the pending change so we can undo if necessary.
1100+
self.push_schema_change(PendingSchemaChange::TableAlterPrimaryKey(table_id, old_pk));
1101+
1102+
Ok(())
1103+
}
1104+
10851105
/// Change the row type of the table identified by `table_id`.
10861106
///
10871107
/// In practice, this should not error,

crates/datastore/src/locking_tx_datastore/tx_state.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ pub enum PendingSchemaChange {
123123
/// Only non-representational row-type changes are allowed here,
124124
/// so existing rows in the table will be compatible with the new row type.
125125
TableAlterRowType(TableId, Vec<ColumnSchema>),
126+
/// The primary key of the table with [`TableId`] was changed.
127+
/// The old primary key was stored.
128+
TableAlterPrimaryKey(TableId, Option<ColList>),
126129
/// The constraint with [`ConstraintSchema`] was added to the table with [`TableId`].
127130
ConstraintRemoved(TableId, ConstraintSchema),
128131
/// The constraint with [`ConstraintId`] was added to the table with [`TableId`].
@@ -146,6 +149,7 @@ impl MemoryUsage for PendingSchemaChange {
146149
Self::TableAdded(table_id) => table_id.heap_usage(),
147150
Self::TableAlterAccess(table_id, st_access) => table_id.heap_usage() + st_access.heap_usage(),
148151
Self::TableAlterRowType(table_id, column_schemas) => table_id.heap_usage() + column_schemas.heap_usage(),
152+
Self::TableAlterPrimaryKey(table_id, pk) => table_id.heap_usage() + pk.heap_usage(),
149153
Self::ConstraintRemoved(table_id, constraint_schema) => {
150154
table_id.heap_usage() + constraint_schema.heap_usage()
151155
}

crates/schema/src/auto_migrate.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,15 @@ pub enum AutoMigrateStep<'def> {
301301
/// Change the access of a table.
302302
ChangeAccess(<TableDef as ModuleDefLookup>::Key<'def>),
303303

304+
/// Change the primary key of a table.
305+
///
306+
/// This updates the `table_primary_key` field in `st_table`
307+
/// to match the new module definition.
308+
/// Without this step, a stale primary key in the stored schema
309+
/// causes `check_compatible` to fail on the next publish.
310+
/// See: <https://github.com/clockworklabs/SpacetimeDB/issues/3934>
311+
ChangePrimaryKey(<TableDef as ModuleDefLookup>::Key<'def>),
312+
304313
/// Recompute a view, update its backing table, and push updates to clients
305314
UpdateView(<ViewDef as ModuleDefLookup>::Key<'def>),
306315

@@ -676,6 +685,9 @@ fn auto_migrate_table<'def>(plan: &mut AutoMigratePlan<'def>, old: &'def TableDe
676685
if old.table_access != new.table_access {
677686
plan.steps.push(AutoMigrateStep::ChangeAccess(key));
678687
}
688+
if old.primary_key != new.primary_key {
689+
plan.steps.push(AutoMigrateStep::ChangePrimaryKey(key));
690+
}
679691
if old.schedule != new.schedule {
680692
// Note: this handles the case where there's an altered ScheduleDef for some reason.
681693
if let Some(old_schedule) = old.schedule.as_ref() {

crates/schema/src/auto_migrate/formatter.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::{
1010
};
1111
use itertools::Itertools;
1212
use spacetimedb_lib::db::raw_def::v9::{RawRowLevelSecurityDefV9, TableAccess, TableType};
13+
use spacetimedb_primitives::ColId;
1314
use spacetimedb_sats::raw_identifier::RawIdentifier;
1415
use spacetimedb_sats::{AlgebraicType, AlgebraicValue, WithTypespace};
1516
use thiserror::Error;
@@ -74,6 +75,11 @@ fn format_step<F: MigrationFormatter>(
7475
let access_info = extract_access_change_info(*table, plan)?;
7576
f.format_change_access(&access_info)
7677
}
78+
AutoMigrateStep::ChangePrimaryKey(table) => {
79+
let old_table = plan.old.lookup_expect::<TableDef>(*table);
80+
let new_table = plan.new.lookup_expect::<TableDef>(*table);
81+
f.format_change_primary_key(table, old_table.primary_key, new_table.primary_key)
82+
}
7783
AutoMigrateStep::AddSchedule(schedule) => {
7884
let schedule_info = extract_schedule_info(*schedule, plan.new)?;
7985
f.format_schedule(&schedule_info, Action::Created)
@@ -150,6 +156,12 @@ pub trait MigrationFormatter {
150156
fn format_constraint(&mut self, constraint_info: &ConstraintInfo, action: Action) -> io::Result<()>;
151157
fn format_sequence(&mut self, sequence_info: &SequenceInfo, action: Action) -> io::Result<()>;
152158
fn format_change_access(&mut self, access_info: &AccessChangeInfo) -> io::Result<()>;
159+
fn format_change_primary_key(
160+
&mut self,
161+
table_name: &str,
162+
old_pk: Option<ColId>,
163+
new_pk: Option<ColId>,
164+
) -> io::Result<()>;
153165
fn format_schedule(&mut self, schedule_info: &ScheduleInfo, action: Action) -> io::Result<()>;
154166
fn format_rls(&mut self, rls_info: &RlsInfo, action: Action) -> io::Result<()>;
155167
fn format_change_columns(&mut self, column_changes: &ColumnChanges) -> io::Result<()>;

crates/schema/src/auto_migrate/termcolor_formatter.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::io::Write;
22
use std::{fmt, io};
33

44
use spacetimedb_lib::{db::raw_def::v9::TableAccess, AlgebraicType};
5+
use spacetimedb_primitives::ColId;
56
use spacetimedb_sats::algebraic_type::fmt::fmt_algebraic_type;
67
use termcolor::{Buffer, Color, ColorChoice, ColorSpec, WriteColor};
78

@@ -324,6 +325,24 @@ impl MigrationFormatter for TermColorFormatter {
324325
self.buffer.write_all(b")\n")
325326
}
326327

328+
fn format_change_primary_key(
329+
&mut self,
330+
table_name: &str,
331+
old_pk: Option<ColId>,
332+
new_pk: Option<ColId>,
333+
) -> io::Result<()> {
334+
let description = match (old_pk, new_pk) {
335+
(Some(_), None) => "removed".to_string(),
336+
(None, Some(col)) => format!("added on column {col}"),
337+
(Some(_), Some(col)) => format!("changed to column {col}"),
338+
(None, None) => return Ok(()),
339+
};
340+
self.write_action_prefix(&Action::Changed)?;
341+
self.buffer.write_all(b" primary key on table ")?;
342+
self.write_colored(table_name, Some(self.colors.table_name), true)?;
343+
self.buffer.write_all(format!(" ({description})\n").as_bytes())
344+
}
345+
327346
fn format_schedule(&mut self, s: &ScheduleInfo, action: Action) -> io::Result<()> {
328347
self.write_action_prefix(&action)?;
329348
self.buffer.write_all(b" schedule for table ")?;

crates/smoketests/tests/smoketests/auto_migration.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,82 @@ fn test_add_table_columns() {
409409
logs2
410410
);
411411
}
412+
413+
// --- Issue #3934: Removing a primary key breaks subsequent publishes ---
414+
415+
const MODULE_CODE_WITH_PK: &str = r#"
416+
use spacetimedb::{ReducerContext, Table};
417+
418+
#[spacetimedb::table(accessor = person, public)]
419+
pub struct Person {
420+
#[primary_key]
421+
name: String,
422+
}
423+
424+
#[spacetimedb::reducer]
425+
pub fn add(ctx: &ReducerContext, name: String) {
426+
ctx.db.person().insert(Person { name });
427+
}
428+
"#;
429+
430+
const MODULE_CODE_WITHOUT_PK: &str = r#"
431+
use spacetimedb::{ReducerContext, Table};
432+
433+
#[spacetimedb::table(accessor = person, public)]
434+
pub struct Person {
435+
name: String,
436+
}
437+
438+
#[spacetimedb::reducer]
439+
pub fn add(ctx: &ReducerContext, name: String) {
440+
ctx.db.person().insert(Person { name });
441+
}
442+
"#;
443+
444+
const MODULE_CODE_WITHOUT_PK_V2: &str = r#"
445+
use spacetimedb::{ReducerContext, Table};
446+
447+
#[spacetimedb::table(accessor = person, public)]
448+
pub struct Person {
449+
name: String,
450+
}
451+
452+
#[spacetimedb::reducer]
453+
pub fn add(ctx: &ReducerContext, name: String) {
454+
ctx.db.person().insert(Person { name });
455+
}
456+
457+
#[spacetimedb::reducer]
458+
pub fn noop(_ctx: &ReducerContext) {}
459+
"#;
460+
461+
/// Regression test for <https://github.com/clockworklabs/SpacetimeDB/issues/3934>.
462+
///
463+
/// Removing a `#[primary_key]` annotation and re-publishing succeeds,
464+
/// but the stored schema retains the stale primary key. On the *next*
465+
/// publish, `check_compatible` sees the mismatch and fails with:
466+
///
467+
/// "Primary key mismatch: self.primary_key: Some(ColId(0)), def.primary_key: None"
468+
///
469+
/// The fix adds a `ChangePrimaryKey` auto-migration step that updates
470+
/// `table_primary_key` in `st_table`.
471+
#[test]
472+
fn test_remove_primary_key_issue_3934() {
473+
let mut test = Smoketest::builder().module_code(MODULE_CODE_WITH_PK).build();
474+
475+
// Step 1: Publish with primary key.
476+
let identity = test
477+
.database_identity
478+
.clone()
479+
.expect("database should be published after build");
480+
481+
// Step 2: Remove primary key. Should succeed.
482+
test.write_module_code(MODULE_CODE_WITHOUT_PK).unwrap();
483+
test.publish_module_with_options(&identity, false, true)
484+
.expect("Removing primary key should succeed");
485+
486+
// Step 3: Trivial change (add a reducer). This is where #3934 crashes.
487+
test.write_module_code(MODULE_CODE_WITHOUT_PK_V2).unwrap();
488+
test.publish_module_with_options(&identity, false, true)
489+
.expect("Publish after PK removal should succeed (issue #3934)");
490+
}

0 commit comments

Comments
 (0)