Skip to content

Commit 294ae04

Browse files
smoketest and fixture
1 parent f71c722 commit 294ae04

8 files changed

Lines changed: 216 additions & 6 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/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.

crates/smoketests/src/lib.rs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ pub struct Smoketest {
448448
/// The SpacetimeDB server guard (stops server on drop).
449449
/// None when running against a remote server.
450450
pub guard: Option<SpacetimeDbGuard>,
451+
/// Owns a copied fixture data directory, if this smoketest was started from one.
452+
_data_dir_fixture: Option<tempfile::TempDir>,
451453
/// Temporary directory containing the module project.
452454
pub project_dir: tempfile::TempDir,
453455
/// Additional features for the spacetimedb bindings dependency.
@@ -718,13 +720,19 @@ impl<'a> SubscribeBuilder<'a> {
718720
pub struct SmoketestBuilder {
719721
module_code: Option<String>,
720722
precompiled_module: Option<String>,
723+
data_dir_fixture: Option<DataDirFixture>,
721724
bindings_features: Vec<String>,
722725
extra_deps: String,
723726
autopublish: bool,
724727
pg_port: Option<u16>,
725728
server_url_override: Option<String>,
726729
}
727730

731+
struct DataDirFixture {
732+
path: PathBuf,
733+
database_identity: String,
734+
}
735+
728736
impl Default for SmoketestBuilder {
729737
fn default() -> Self {
730738
Self::new()
@@ -737,6 +745,7 @@ impl SmoketestBuilder {
737745
Self {
738746
module_code: None,
739747
precompiled_module: None,
748+
data_dir_fixture: None,
740749
bindings_features: vec!["unstable".to_string()],
741750
extra_deps: String::new(),
742751
autopublish: true,
@@ -750,6 +759,18 @@ impl SmoketestBuilder {
750759
self
751760
}
752761

762+
/// Starts the local server from a copy of a persisted standalone data directory fixture.
763+
///
764+
/// The fixture directory is copied to a temporary directory before startup so tests can
765+
/// freely mutate it. Tests using this should normally also call `autopublish(false)`.
766+
pub fn data_dir_fixture(mut self, path: impl AsRef<Path>, database_identity: impl Into<String>) -> Self {
767+
self.data_dir_fixture = Some(DataDirFixture {
768+
path: path.as_ref().to_path_buf(),
769+
database_identity: database_identity.into(),
770+
});
771+
self
772+
}
773+
753774
/// Enables the PostgreSQL wire protocol on the specified port.
754775
pub fn pg_port(mut self, port: u16) -> Self {
755776
self.pg_port = Some(port);
@@ -822,20 +843,50 @@ impl SmoketestBuilder {
822843
let _ = ensure_binaries_built();
823844
let build_start = Instant::now();
824845

846+
let fixture_identity = self
847+
.data_dir_fixture
848+
.as_ref()
849+
.map(|fixture| fixture.database_identity.clone());
850+
825851
// Check if we're running against a remote server
826-
let (guard, server_url) = if let Some(url) = self.server_url_override {
852+
let (guard, server_url, data_dir_fixture) = if let Some(fixture) = self.data_dir_fixture.as_ref() {
853+
if self.server_url_override.is_some() || remote_server_url().is_some() {
854+
panic!("data_dir_fixture requires a local server managed by the smoketest harness");
855+
}
856+
857+
let temp_dir = tempfile::tempdir().expect("Failed to create temp data fixture directory");
858+
let copy_options = fs_extra::dir::CopyOptions {
859+
content_only: true,
860+
overwrite: true,
861+
..Default::default()
862+
};
863+
fs_extra::dir::copy(&fixture.path, temp_dir.path(), &copy_options).unwrap_or_else(|err| {
864+
panic!(
865+
"failed to copy data dir fixture from {} to {}: {err:#}",
866+
fixture.path.display(),
867+
temp_dir.path().display()
868+
)
869+
});
870+
871+
let guard = timed!(
872+
"server spawn from data dir fixture",
873+
SpacetimeDbGuard::spawn_with_data_dir(temp_dir.path().to_path_buf(), self.pg_port)
874+
);
875+
let url = guard.host_url.clone();
876+
(Some(guard), url, Some(temp_dir))
877+
} else if let Some(url) = self.server_url_override {
827878
eprintln!("[REMOTE] Using explicit server URL: {}", url);
828-
(None, url)
879+
(None, url, None)
829880
} else if let Some(remote_url) = remote_server_url() {
830881
eprintln!("[REMOTE] Using remote server: {}", remote_url);
831-
(None, remote_url)
882+
(None, remote_url, None)
832883
} else {
833884
let guard = timed!(
834885
"server spawn",
835886
SpacetimeDbGuard::spawn_in_temp_data_dir_with_pg_port(self.pg_port)
836887
);
837888
let url = guard.host_url.clone();
838-
(Some(guard), url)
889+
(Some(guard), url, None)
839890
};
840891

841892
let project_dir = tempfile::tempdir().expect("Failed to create temp project directory");
@@ -863,8 +914,9 @@ impl SmoketestBuilder {
863914
let config_path = project_dir.path().join("config.toml");
864915
let mut smoketest = Smoketest {
865916
guard,
917+
_data_dir_fixture: data_dir_fixture,
866918
project_dir,
867-
database_identity: None,
919+
database_identity: fixture_identity,
868920
server_url,
869921
config_path,
870922
module_name,

crates/smoketests/tests/smoketests/views.rs

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
use std::path::PathBuf;
2+
13
use serde_json::{json, Value};
2-
use spacetimedb_smoketests::{require_dotnet, require_pnpm, ModuleLanguage, Smoketest};
4+
use spacetimedb_smoketests::{
5+
require_dotnet, require_local_server, require_pnpm, workspace_root, ModuleLanguage, Smoketest,
6+
};
7+
8+
const STALE_VIEW_BACKING_TABLE_FIXTURE_IDENTITY: &str =
9+
"c200f6ec405075e508c2ed6474019332d6a2a46c69614306cc4bd980e0b8b767";
310

411
const TS_VIEWS_SUBSCRIBE_MODULE: &str = r#"import { schema, t, table } from "spacetimedb/server";
512
@@ -218,6 +225,27 @@ fn project_fields(events: Vec<Value>, view_name: &str, projected_fields: &[&str]
218225
.collect()
219226
}
220227

228+
fn stale_view_backing_table_fixture() -> PathBuf {
229+
workspace_root()
230+
.join("crates")
231+
.join("smoketests")
232+
.join("fixtures")
233+
.join("stale-view-backing-table-v2.6.0")
234+
}
235+
236+
fn stale_view_backing_table_test() -> Smoketest {
237+
let test = Smoketest::builder()
238+
.data_dir_fixture(
239+
stale_view_backing_table_fixture(),
240+
STALE_VIEW_BACKING_TABLE_FIXTURE_IDENTITY,
241+
)
242+
.autopublish(false)
243+
.build();
244+
245+
test.new_identity().unwrap();
246+
test
247+
}
248+
221249
fn assert_count_view_refresh_behavior(test: &Smoketest, view_name: &str, id: &str, value: &str, updated_value: &str) {
222250
let query = format!("select * from {view_name}");
223251
let sub = test.subscribe(&[&query]).expect_rows(2).background().unwrap();
@@ -621,6 +649,78 @@ fn test_view_primary_key_auto_migration_disconnects_clients() {
621649
);
622650
}
623651

652+
#[test]
653+
fn test_repair_stale_sender_scoped_view_backing_table_on_startup() {
654+
require_local_server!();
655+
656+
let test = stale_view_backing_table_test();
657+
658+
let sender_view_sub = test
659+
.subscribe(&["select * from player"])
660+
.expect_rows(2)
661+
.background()
662+
.unwrap();
663+
664+
test.call("set_player_state", &["42", "1"]).unwrap();
665+
test.call("set_player_state", &["42", "2"]).unwrap();
666+
667+
let sender_view_events = sender_view_sub.collect().unwrap();
668+
let sender_view_projection = project_fields(sender_view_events, "player", &["id", "level"]);
669+
assert_eq!(
670+
serde_json::json!(sender_view_projection),
671+
json!([
672+
{
673+
"player": {
674+
"deletes": [],
675+
"inserts": [{ "id": 42, "level": 1 }]
676+
}
677+
},
678+
{
679+
"player": {
680+
"deletes": [{ "id": 42, "level": 1 }],
681+
"inserts": [{ "id": 42, "level": 2 }]
682+
}
683+
}
684+
])
685+
);
686+
}
687+
688+
#[test]
689+
fn test_repair_stale_anonymous_view_backing_table_on_startup() {
690+
require_local_server!();
691+
692+
let test = stale_view_backing_table_test();
693+
694+
let anonymous_view_sub = test
695+
.subscribe(&["select * from player_and_level"])
696+
.expect_rows(2)
697+
.background()
698+
.unwrap();
699+
700+
test.call("add_player_level", &["1", "2"]).unwrap();
701+
test.call("add_player_level", &["2", "2"]).unwrap();
702+
703+
let anonymous_view_events = anonymous_view_sub.collect().unwrap();
704+
let anonymous_view_projection = project_fields(anonymous_view_events, "player_and_level", &["id", "level"]);
705+
assert_eq!(
706+
serde_json::json!(anonymous_view_projection),
707+
json!([
708+
{
709+
"player_and_level": {
710+
"deletes": [],
711+
"inserts": [{ "id": 1, "level": 2 }]
712+
}
713+
},
714+
{
715+
"player_and_level": {
716+
"deletes": [],
717+
"inserts": [{ "id": 2, "level": 2 }]
718+
}
719+
}
720+
])
721+
);
722+
}
723+
624724
#[test]
625725
fn test_view_accessibility() {
626726
let test = Smoketest::builder().precompiled_module("views-callable").build();

0 commit comments

Comments
 (0)