Skip to content

Commit 1c388fb

Browse files
smoketest and fixture
1 parent 679a1b2 commit 1c388fb

6 files changed

Lines changed: 255 additions & 6 deletions

File tree

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: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,25 @@ pub fn patch_module_cargo_to_local_bindings(module_dir: &Path) -> Result<()> {
198198
Ok(())
199199
}
200200

201+
fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
202+
fs::create_dir_all(dst).with_context(|| format!("Failed to create {}", dst.display()))?;
203+
for entry in fs::read_dir(src).with_context(|| format!("Failed to read {}", src.display()))? {
204+
let entry = entry?;
205+
let src_path = entry.path();
206+
let dst_path = dst.join(entry.file_name());
207+
let file_type = entry.file_type()?;
208+
if file_type.is_dir() {
209+
copy_dir_contents(&src_path, &dst_path)?;
210+
} else if file_type.is_file() {
211+
fs::copy(&src_path, &dst_path)
212+
.with_context(|| format!("Failed to copy {} to {}", src_path.display(), dst_path.display()))?;
213+
} else {
214+
bail!("Unsupported fixture entry type at {}", src_path.display());
215+
}
216+
}
217+
Ok(())
218+
}
219+
201220
/// Returns the shared target directory for smoketest module builds.
202221
///
203222
/// All tests share this directory to cache compiled dependencies. The warmup step
@@ -448,6 +467,8 @@ pub struct Smoketest {
448467
/// The SpacetimeDB server guard (stops server on drop).
449468
/// None when running against a remote server.
450469
pub guard: Option<SpacetimeDbGuard>,
470+
/// Owns a copied fixture data directory, if this smoketest was started from one.
471+
_data_dir_fixture: Option<tempfile::TempDir>,
451472
/// Temporary directory containing the module project.
452473
pub project_dir: tempfile::TempDir,
453474
/// Additional features for the spacetimedb bindings dependency.
@@ -718,13 +739,19 @@ impl<'a> SubscribeBuilder<'a> {
718739
pub struct SmoketestBuilder {
719740
module_code: Option<String>,
720741
precompiled_module: Option<String>,
742+
data_dir_fixture: Option<DataDirFixture>,
721743
bindings_features: Vec<String>,
722744
extra_deps: String,
723745
autopublish: bool,
724746
pg_port: Option<u16>,
725747
server_url_override: Option<String>,
726748
}
727749

750+
struct DataDirFixture {
751+
path: PathBuf,
752+
database_identity: String,
753+
}
754+
728755
impl Default for SmoketestBuilder {
729756
fn default() -> Self {
730757
Self::new()
@@ -737,6 +764,7 @@ impl SmoketestBuilder {
737764
Self {
738765
module_code: None,
739766
precompiled_module: None,
767+
data_dir_fixture: None,
740768
bindings_features: vec!["unstable".to_string()],
741769
extra_deps: String::new(),
742770
autopublish: true,
@@ -750,6 +778,18 @@ impl SmoketestBuilder {
750778
self
751779
}
752780

781+
/// Starts the local server from a copy of a persisted standalone data directory fixture.
782+
///
783+
/// The fixture directory is copied to a temporary directory before startup so tests can
784+
/// freely mutate it. Tests using this should normally also call `autopublish(false)`.
785+
pub fn data_dir_fixture(mut self, path: impl AsRef<Path>, database_identity: impl Into<String>) -> Self {
786+
self.data_dir_fixture = Some(DataDirFixture {
787+
path: path.as_ref().to_path_buf(),
788+
database_identity: database_identity.into(),
789+
});
790+
self
791+
}
792+
753793
/// Enables the PostgreSQL wire protocol on the specified port.
754794
pub fn pg_port(mut self, port: u16) -> Self {
755795
self.pg_port = Some(port);
@@ -822,20 +862,45 @@ impl SmoketestBuilder {
822862
let _ = ensure_binaries_built();
823863
let build_start = Instant::now();
824864

865+
let fixture_identity = self
866+
.data_dir_fixture
867+
.as_ref()
868+
.map(|fixture| fixture.database_identity.clone());
869+
825870
// Check if we're running against a remote server
826-
let (guard, server_url) = if let Some(url) = self.server_url_override {
871+
let (guard, server_url, data_dir_fixture) = if let Some(fixture) = self.data_dir_fixture.as_ref() {
872+
if self.server_url_override.is_some() || remote_server_url().is_some() {
873+
panic!("data_dir_fixture requires a local server managed by the smoketest harness");
874+
}
875+
876+
let temp_dir = tempfile::tempdir().expect("Failed to create temp data fixture directory");
877+
copy_dir_contents(&fixture.path, temp_dir.path()).unwrap_or_else(|err| {
878+
panic!(
879+
"failed to copy data dir fixture from {} to {}: {err:#}",
880+
fixture.path.display(),
881+
temp_dir.path().display()
882+
)
883+
});
884+
885+
let guard = timed!(
886+
"server spawn from data dir fixture",
887+
SpacetimeDbGuard::spawn_with_data_dir(temp_dir.path().to_path_buf(), self.pg_port)
888+
);
889+
let url = guard.host_url.clone();
890+
(Some(guard), url, Some(temp_dir))
891+
} else if let Some(url) = self.server_url_override {
827892
eprintln!("[REMOTE] Using explicit server URL: {}", url);
828-
(None, url)
893+
(None, url, None)
829894
} else if let Some(remote_url) = remote_server_url() {
830895
eprintln!("[REMOTE] Using remote server: {}", remote_url);
831-
(None, remote_url)
896+
(None, remote_url, None)
832897
} else {
833898
let guard = timed!(
834899
"server spawn",
835900
SpacetimeDbGuard::spawn_in_temp_data_dir_with_pg_port(self.pg_port)
836901
);
837902
let url = guard.host_url.clone();
838-
(Some(guard), url)
903+
(Some(guard), url, None)
839904
};
840905

841906
let project_dir = tempfile::tempdir().expect("Failed to create temp project directory");
@@ -863,8 +928,9 @@ impl SmoketestBuilder {
863928
let config_path = project_dir.path().join("config.toml");
864929
let mut smoketest = Smoketest {
865930
guard,
931+
_data_dir_fixture: data_dir_fixture,
866932
project_dir,
867-
database_identity: None,
933+
database_identity: fixture_identity,
868934
server_url,
869935
config_path,
870936
module_name,

crates/smoketests/tests/smoketests/views.rs

Lines changed: 128 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,105 @@ 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+
test.assert_sql(
659+
"SELECT * FROM player",
660+
r#" id | level
661+
----+-------"#,
662+
);
663+
664+
let sender_view_sub = test
665+
.subscribe(&["select * from player"])
666+
.expect_rows(2)
667+
.background()
668+
.unwrap();
669+
670+
test.call("set_player_state", &["42", "1"]).unwrap();
671+
test.call("set_player_state", &["42", "2"]).unwrap();
672+
673+
let sender_view_events = sender_view_sub.collect().unwrap();
674+
let sender_view_projection = project_fields(sender_view_events, "player", &["id", "level"]);
675+
assert_eq!(
676+
serde_json::json!(sender_view_projection),
677+
json!([
678+
{
679+
"player": {
680+
"deletes": [],
681+
"inserts": [{ "id": 42, "level": 1 }]
682+
}
683+
},
684+
{
685+
"player": {
686+
"deletes": [{ "id": 42, "level": 1 }],
687+
"inserts": [{ "id": 42, "level": 2 }]
688+
}
689+
}
690+
])
691+
);
692+
693+
test.assert_sql(
694+
"SELECT * FROM player",
695+
r#" id | level
696+
----+-------
697+
42 | 2"#,
698+
);
699+
}
700+
701+
#[test]
702+
fn test_repair_stale_anonymous_view_backing_table_on_startup() {
703+
require_local_server!();
704+
705+
let test = stale_view_backing_table_test();
706+
707+
test.assert_sql(
708+
"SELECT * FROM player_and_level",
709+
r#" id | level
710+
----+-------"#,
711+
);
712+
713+
let anonymous_view_sub = test
714+
.subscribe(&["select * from player_and_level"])
715+
.expect_rows(2)
716+
.background()
717+
.unwrap();
718+
719+
test.call("add_player_level", &["1", "2"]).unwrap();
720+
test.call("add_player_level", &["2", "2"]).unwrap();
721+
722+
let anonymous_view_events = anonymous_view_sub.collect().unwrap();
723+
let anonymous_view_projection = project_fields(anonymous_view_events, "player_and_level", &["id", "level"]);
724+
assert_eq!(
725+
serde_json::json!(anonymous_view_projection),
726+
json!([
727+
{
728+
"player_and_level": {
729+
"deletes": [],
730+
"inserts": [{ "id": 1, "level": 2 }]
731+
}
732+
},
733+
{
734+
"player_and_level": {
735+
"deletes": [],
736+
"inserts": [{ "id": 2, "level": 2 }]
737+
}
738+
}
739+
])
740+
);
741+
742+
test.assert_sql(
743+
"SELECT * FROM player_and_level",
744+
r#" id | level
745+
----+-------
746+
1 | 2
747+
2 | 2"#,
748+
);
749+
}
750+
624751
#[test]
625752
fn test_view_accessibility() {
626753
let test = Smoketest::builder().precompiled_module("views-callable").build();

0 commit comments

Comments
 (0)