Skip to content

Commit 208ee4b

Browse files
Automigrate views using new schema for backing table (#5441)
# Description of Changes #5300 changed the backing table schema of views. While this technically isn't a breaking change because these tables are ephemeral, the schema is not ephemeral, so it does require us to update the schema by dropping and re-adding views once on initial startup. # API and ABI breaking changes None # Expected complexity level and risk 1.5 # Testing Auto-migration unit tests
1 parent 5e6073e commit 208ee4b

11 files changed

Lines changed: 427 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/src/host/host_controller.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ use spacetimedb_datastore::execution_context::Workload;
3737
use spacetimedb_datastore::system_tables::ModuleKind;
3838
use spacetimedb_datastore::traits::Program;
3939
use spacetimedb_durability::{self as durability};
40-
use spacetimedb_lib::{AlgebraicValue, Identity, Timestamp};
40+
use spacetimedb_lib::{identity::AuthCtx, AlgebraicValue, Identity, Timestamp};
4141
use spacetimedb_paths::server::{ModuleLogsDir, ServerDataDir};
4242
use spacetimedb_runtime::AbortHandle;
4343
use spacetimedb_sats::hash::Hash;
@@ -941,6 +941,31 @@ impl<F: Fn() + Send + Sync + 'static> ModuleLauncher<F> {
941941
}
942942
}
943943

944+
fn repair_stale_view_backing_tables_on_launch(launched: &LaunchedModule) -> anyhow::Result<()> {
945+
let info = launched.module_host.info();
946+
let stdb = info.relational_db().clone();
947+
let Some(plan) = db::update::stale_view_backing_table_recreate_plan(stdb.as_ref(), &info.module_def)? else {
948+
return Ok(());
949+
};
950+
951+
info!(
952+
"repairing stale view backing tables during module launch: {}",
953+
info.database_identity
954+
);
955+
let system_logger = launched.replica_ctx.logger.system_logger();
956+
system_logger.info("Repairing stale view backing tables");
957+
958+
let auth_ctx = AuthCtx::for_current(info.owner_identity);
959+
stdb.with_auto_commit(Workload::Internal, |tx| -> anyhow::Result<()> {
960+
match db::update::update_database(stdb.as_ref(), tx, auth_ctx, plan, system_logger)? {
961+
db::update::UpdateResult::Success | db::update::UpdateResult::RequiresClientDisconnect => Ok(()),
962+
db::update::UpdateResult::EvaluateSubscribedViews => {
963+
bail!("startup view backing table repair unexpectedly requested view evaluation")
964+
}
965+
}
966+
})
967+
}
968+
944969
/// Update a module.
945970
///
946971
/// If the `db` is not initialized yet (i.e. its program hash is `None`),
@@ -1208,6 +1233,7 @@ impl Host {
12081233
launched.module_host.durable_tx_offset(),
12091234
));
12101235
} else {
1236+
repair_stale_view_backing_tables_on_launch(&launched)?;
12111237
drop(program)
12121238
}
12131239

crates/engine/src/update.rs

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
use super::relational_db::RelationalDB;
22
use crate::sql::rls::RowLevelExpr;
33
use anyhow::Context;
4-
use spacetimedb_datastore::locking_tx_datastore::MutTxId;
4+
use spacetimedb_datastore::execution_context::Workload;
5+
use spacetimedb_datastore::locking_tx_datastore::state_view::StateView;
6+
use spacetimedb_datastore::locking_tx_datastore::{MutTxId, TxId};
7+
use spacetimedb_datastore::system_tables::{StViewFields, StViewRow, ST_VIEW_ID};
58
use spacetimedb_lib::db::auth::StTableType;
69
use spacetimedb_lib::identity::AuthCtx;
710
use spacetimedb_lib::AlgebraicValue;
811
use spacetimedb_primitives::{ColSet, TableId};
9-
use spacetimedb_schema::auto_migrate::{AutoMigratePlan, ManualMigratePlan, MigratePlan};
10-
use spacetimedb_schema::def::{ModuleDef, TableDef, ViewDef};
12+
use spacetimedb_schema::auto_migrate::{AutoMigratePlan, AutoMigrateStep, ManualMigratePlan, MigratePlan};
13+
use spacetimedb_schema::def::{ModuleDef, ModuleDefLookup, TableDef, ViewDef};
1114
use spacetimedb_schema::schema::{
1215
column_schemas_from_defs, ConstraintSchema, IndexSchema, Schema, SequenceSchema, TableSchema,
1316
};
@@ -26,6 +29,91 @@ pub enum UpdateResult {
2629
EvaluateSubscribedViews,
2730
}
2831

32+
/// Build a repair-only migration plan for views whose stored backing table row
33+
/// layout is stale compared to the currently loaded module definition.
34+
pub fn stale_view_backing_table_recreate_plan<'def>(
35+
stdb: &RelationalDB,
36+
module_def: &'def ModuleDef,
37+
) -> anyhow::Result<Option<MigratePlan<'def>>> {
38+
let steps = stale_view_backing_table_recreate_steps(stdb, module_def)?;
39+
if steps.is_empty() {
40+
return Ok(None);
41+
}
42+
43+
Ok(Some(MigratePlan::Auto(AutoMigratePlan {
44+
old: module_def,
45+
new: module_def,
46+
prechecks: Vec::new(),
47+
steps,
48+
})))
49+
}
50+
51+
fn stale_view_backing_table_recreate_steps<'def>(
52+
stdb: &RelationalDB,
53+
module_def: &'def ModuleDef,
54+
) -> anyhow::Result<Vec<AutoMigrateStep<'def>>> {
55+
stdb.with_read_only(Workload::Internal, |tx| -> anyhow::Result<_> {
56+
let mut steps = Vec::new();
57+
58+
for view in module_def.views() {
59+
if view_backing_table_needs_recreate(stdb, tx, module_def, view)? {
60+
steps.extend([
61+
AutoMigrateStep::RemoveView(view.key()),
62+
AutoMigrateStep::AddView(view.key()),
63+
]);
64+
}
65+
}
66+
67+
if !steps.is_empty() {
68+
ensure_disconnect_all_users(&mut steps);
69+
steps.sort();
70+
}
71+
72+
Ok(steps)
73+
})
74+
}
75+
76+
fn view_backing_table_needs_recreate(
77+
stdb: &RelationalDB,
78+
tx: &mut TxId,
79+
module_def: &ModuleDef,
80+
view: &ViewDef,
81+
) -> anyhow::Result<bool> {
82+
let Some(table_id) = view_backing_table_id(tx, view)? else {
83+
return Ok(false);
84+
};
85+
86+
let actual = stdb.schema_for_table(tx, table_id)?;
87+
let expected = TableSchema::from_view_def_for_datastore(module_def, view);
88+
89+
Ok(view_backing_row_layout_changed(&actual, &expected))
90+
}
91+
92+
fn view_backing_table_id(tx: &mut TxId, view: &ViewDef) -> anyhow::Result<Option<TableId>> {
93+
let view_name = AlgebraicValue::from(<Box<str>>::from(&*view.name));
94+
let Some(row) = tx
95+
.iter_by_col_eq(ST_VIEW_ID, StViewFields::ViewName, &view_name)?
96+
.next()
97+
else {
98+
return Ok(None);
99+
};
100+
101+
Ok(StViewRow::try_from(row)?.table_id)
102+
}
103+
104+
fn view_backing_row_layout_changed(actual: &TableSchema, expected: &TableSchema) -> bool {
105+
actual.row_type != expected.row_type
106+
}
107+
108+
fn ensure_disconnect_all_users(steps: &mut Vec<AutoMigrateStep<'_>>) {
109+
if !steps
110+
.iter()
111+
.any(|step| matches!(step, AutoMigrateStep::DisconnectAllUsers))
112+
{
113+
steps.push(AutoMigrateStep::DisconnectAllUsers);
114+
}
115+
}
116+
29117
/// Update the database according to the migration plan.
30118
///
31119
/// The update is performed within the transactional context `tx`.
@@ -575,6 +663,86 @@ mod test {
575663
.expect("should be a valid module definition")
576664
}
577665

666+
fn view_module() -> ModuleDef {
667+
let mut builder = RawModuleDefV10Builder::new();
668+
let return_type_ref = builder.add_algebraic_type(
669+
[],
670+
"my_view_return_type",
671+
AlgebraicType::product([("a", AlgebraicType::U64)]),
672+
true,
673+
);
674+
builder.add_view(
675+
"my_view",
676+
0,
677+
true,
678+
true,
679+
ProductType::unit(),
680+
AlgebraicType::array(AlgebraicType::Ref(return_type_ref)),
681+
);
682+
builder.add_view_primary_key("my_view", ["a"]);
683+
builder
684+
.finish()
685+
.try_into()
686+
.expect("should be a valid module definition")
687+
}
688+
689+
fn old_view_backing_schema(module_def: &ModuleDef) -> TableSchema {
690+
let view = module_def.view("my_view").unwrap();
691+
let mut schema = TableSchema::from_view_def_for_datastore(module_def, view);
692+
693+
schema.columns.remove(0);
694+
for (pos, col) in schema.columns.iter_mut().enumerate() {
695+
col.col_pos = pos.into();
696+
}
697+
schema.indexes.clear();
698+
schema.constraints.clear();
699+
schema.reset();
700+
701+
schema
702+
}
703+
704+
fn create_backing_table(stdb: &TestDB, schema: TableSchema) -> anyhow::Result<()> {
705+
let mut tx = begin_mut_tx(stdb);
706+
stdb.create_table(&mut tx, schema)?;
707+
stdb.commit_tx(tx)?;
708+
Ok(())
709+
}
710+
711+
#[test]
712+
fn stale_view_backing_schema_generates_startup_repair_plan() -> anyhow::Result<()> {
713+
let stdb = TestDB::durable()?;
714+
let module_def = view_module();
715+
create_backing_table(&stdb, old_view_backing_schema(&module_def))?;
716+
717+
let plan = stale_view_backing_table_recreate_plan(&stdb, &module_def)?.expect("expected repair plan");
718+
719+
let MigratePlan::Auto(plan) = &plan else {
720+
panic!("expected auto migration");
721+
};
722+
let my_view = module_def.view("my_view").unwrap().key();
723+
assert!(plan.steps.contains(&AutoMigrateStep::RemoveView(my_view)));
724+
assert!(plan.steps.contains(&AutoMigrateStep::AddView(my_view)));
725+
assert!(plan.steps.contains(&AutoMigrateStep::DisconnectAllUsers));
726+
assert!(!plan.steps.contains(&AutoMigrateStep::UpdateView(my_view)));
727+
728+
Ok(())
729+
}
730+
731+
#[test]
732+
fn current_view_backing_schema_skips_startup_repair_plan() -> anyhow::Result<()> {
733+
let stdb = TestDB::durable()?;
734+
let module_def = view_module();
735+
let view = module_def.view("my_view").unwrap();
736+
let backing_schema = TableSchema::from_view_def_for_datastore(&module_def, view);
737+
create_backing_table(&stdb, backing_schema)?;
738+
739+
let plan = stale_view_backing_table_recreate_plan(&stdb, &module_def)?;
740+
741+
assert!(plan.is_none(), "{plan:#?}");
742+
743+
Ok(())
744+
}
745+
578746
enum TakeSnapshot {
579747
None,
580748
BeforeAutomigration,

crates/schema/src/auto_migrate.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,25 @@ impl MigrationPolicy {
131131
new_module_def: &'def ModuleDef,
132132
) -> anyhow::Result<MigratePlan<'def>, MigrationPolicyError> {
133133
let plan = ponder_migrate(old_module_def, new_module_def).map_err(MigrationPolicyError::AutoMigrateFailure)?;
134+
self.permits_migrate_plan(database_identity, old_module_hash, new_module_hash, &plan)?;
135+
Ok(plan)
136+
}
134137

138+
/// Validate an already-generated migration plan under this policy.
139+
pub fn permits_migrate_plan(
140+
&self,
141+
database_identity: Identity,
142+
old_module_hash: spacetimedb_lib::Hash,
143+
new_module_hash: spacetimedb_lib::Hash,
144+
plan: &MigratePlan<'_>,
145+
) -> anyhow::Result<(), MigrationPolicyError> {
135146
let token = MigrationToken {
136147
database_identity,
137148
old_module_hash,
138149
new_module_hash,
139150
};
140-
self.permits_plan(&plan, &token)?;
141-
Ok(plan)
151+
self.permits_plan(plan, &token)?;
152+
Ok(())
142153
}
143154
}
144155

crates/smoketests/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ serde_json.workspace = true
1212
toml.workspace = true
1313
regex.workspace = true
1414
anyhow.workspace = true
15+
fs_extra.workspace = true
1516
which = "8.0.0"
1617

1718
[dev-dependencies]

crates/smoketests/fixtures/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,55 @@ CARGO_NET_OFFLINE=true CARGO_TARGET_DIR="$TMP/target-old" \
4444
cp "$TMP/target-old/wasm32-unknown-unknown/release/upgrade_old_module.wasm" \
4545
crates/smoketests/fixtures/upgrade_old_module_v1.wasm
4646
```
47+
48+
## `stale-view-backing-table-v2.6.0`
49+
50+
This is a standalone data-dir fixture created by `spacetimedb-standalone` `v2.6.0`.
51+
It contains database identity
52+
`c200f6ec405075e508c2ed6474019332d6a2a46c69614306cc4bd980e0b8b767`, published
53+
from `crates/smoketests/modules/views-sql`.
54+
55+
The fixture exists to cover startup repair for view backing tables created before
56+
the `arg_hash` internal column. The persisted sender-scoped view backing tables
57+
begin with `sender`, and anonymous view backing tables do not have an internal
58+
argument column.
59+
60+
The checked-in fixture intentionally keeps only the control database entry and
61+
commitlog segment required to replay that database state. The current server can
62+
recreate config, metadata, commitlog offset indexes (`*.stdb.ofs`), snapshots,
63+
lock files, and program byte storage from defaults and the commitlog during
64+
startup.
65+
66+
To regenerate it:
67+
68+
```bash
69+
TMP="$(mktemp -d)"
70+
git archive --format=tar v2.6.0 | tar -xf - -C "$TMP"
71+
72+
cargo build --manifest-path "$TMP/Cargo.toml" \
73+
-p spacetimedb-standalone --release \
74+
--features spacetimedb-standalone/allow_loopback_http_for_tests
75+
76+
"$TMP/target/release/spacetimedb-standalone" start \
77+
--data-dir "$TMP/data" \
78+
--jwt-key-dir "$TMP/keys" \
79+
--listen-addr 127.0.0.1:0 \
80+
--non-interactive
81+
82+
# In another shell, publish to the printed server URL.
83+
CARGO_HOME="$TMP/cargo-home" CARGO_TARGET_DIR="$TMP/module-target" \
84+
target/release/spacetimedb-cli --config-path "$TMP/client-config.toml" publish \
85+
--server "http://127.0.0.1:<PORT>" \
86+
--module-path crates/smoketests/modules/views-sql \
87+
--yes stale-view-backing-table-v26
88+
89+
# Stop the old server, update the identity constant if it changed, then copy the
90+
# minimal fixture.
91+
FIXTURE=crates/smoketests/fixtures/stale-view-backing-table-v2.6.0
92+
rsync -am \
93+
--include='*/' \
94+
--include='control-db/db' \
95+
--include='replicas/1/clog/*.stdb.log' \
96+
--exclude='*' \
97+
"$TMP/data/" "$FIXTURE/"
98+
```
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
!replicas/
2+
!replicas/1/
3+
!replicas/1/clog/
4+
!replicas/1/clog/*.stdb.log
Binary file not shown.

0 commit comments

Comments
 (0)