aster_forge_test/
fixture.rs

1//! Cross-process suite fixture metadata.
2//!
3//! Containers and per-test databases are short-lived mechanics. A migrated template database or
4//! schema snapshot is different: it is a suite-scoped product fixture that can be consumed by
5//! many nextest processes after its producer exits. This module owns only the locked, atomic
6//! metadata publication protocol; products own fixture names, contents, validation, and cleanup.
7
8use crate::suite::TestContainerSuite;
9use fs2::FileExt;
10use serde::{Deserialize, Serialize};
11use std::fs::{self, File, OpenOptions};
12use std::io::{Read, Write};
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16const FIXTURE_STATE_FORMAT_VERSION: u32 = 1;
17static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
18
19/// Atomically published identity of a suite-scoped product fixture.
20#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
21pub struct SuiteFixtureState {
22    format_version: u32,
23    fixture: String,
24    container_identity: String,
25    fingerprint: String,
26    resource: String,
27    producer_version: String,
28}
29
30impl SuiteFixtureState {
31    /// Creates state for a fully initialized product fixture.
32    #[must_use]
33    pub fn new(
34        fixture: impl Into<String>,
35        container_identity: impl Into<String>,
36        fingerprint: impl Into<String>,
37        resource: impl Into<String>,
38        producer_version: impl Into<String>,
39    ) -> Self {
40        let state = Self {
41            format_version: FIXTURE_STATE_FORMAT_VERSION,
42            fixture: fixture.into(),
43            container_identity: container_identity.into(),
44            fingerprint: fingerprint.into(),
45            resource: resource.into(),
46            producer_version: producer_version.into(),
47        };
48        state.assert_valid();
49        state
50    }
51
52    /// Returns the product-defined fixture kind, such as `postgres-template`.
53    #[must_use]
54    pub fn fixture(&self) -> &str {
55        &self.fixture
56    }
57
58    /// Returns the shared container identity this fixture was built in.
59    #[must_use]
60    pub fn container_identity(&self) -> &str {
61        &self.container_identity
62    }
63
64    /// Returns the product-defined migration or schema fingerprint.
65    #[must_use]
66    pub fn fingerprint(&self) -> &str {
67        &self.fingerprint
68    }
69
70    /// Returns the product-defined backing resource name.
71    #[must_use]
72    pub fn resource(&self) -> &str {
73        &self.resource
74    }
75
76    /// Returns the producer implementation version.
77    #[must_use]
78    pub fn producer_version(&self) -> &str {
79        &self.producer_version
80    }
81
82    /// Returns whether state belongs to the requested fixture contract.
83    #[must_use]
84    pub fn matches(
85        &self,
86        fixture: &str,
87        container_identity: &str,
88        fingerprint: &str,
89        producer_version: &str,
90    ) -> bool {
91        self.format_version == FIXTURE_STATE_FORMAT_VERSION
92            && self.fixture == fixture
93            && self.container_identity == container_identity
94            && self.fingerprint == fingerprint
95            && self.producer_version == producer_version
96    }
97
98    fn assert_valid(&self) {
99        assert_eq!(
100            self.format_version, FIXTURE_STATE_FORMAT_VERSION,
101            "unsupported suite fixture state format version {}",
102            self.format_version
103        );
104        for (field, value) in [
105            ("fixture", self.fixture.as_str()),
106            ("container_identity", self.container_identity.as_str()),
107            ("fingerprint", self.fingerprint.as_str()),
108            ("resource", self.resource.as_str()),
109            ("producer_version", self.producer_version.as_str()),
110        ] {
111            assert!(
112                !value.is_empty() && !value.contains(['\r', '\n']),
113                "suite fixture state {field} must be a non-empty single-line value"
114            );
115        }
116    }
117}
118
119/// Exclusive lock and atomic state file for one suite fixture.
120///
121/// Keep this guard for the complete validate-or-rebuild transaction. A producer that exits before
122/// [`Self::publish`] leaves no visible fixture state, so the next process can safely clean the
123/// product's deterministic candidate resource and rebuild it.
124pub struct SuiteFixtureLock {
125    _file: File,
126    state_path: PathBuf,
127}
128
129impl SuiteFixtureLock {
130    /// Acquires the cross-process lock for one suite fixture.
131    ///
132    /// # Panics
133    ///
134    /// Panics when the fixture name is invalid or the lock file cannot be opened or locked.
135    #[must_use]
136    pub fn acquire(suite: &TestContainerSuite, fixture: &str) -> Self {
137        assert_valid_fixture_name(fixture);
138        let state_path = suite.fixture_state_path(fixture);
139        let lock_path = suite.fixture_lock_path(fixture);
140        let file = OpenOptions::new()
141            .create(true)
142            .truncate(false)
143            .read(true)
144            .write(true)
145            .open(&lock_path)
146            .unwrap_or_else(|error| {
147                panic!(
148                    "failed to open suite fixture lock {}: {error}",
149                    lock_path.display()
150                )
151            });
152        file.lock_exclusive().unwrap_or_else(|error| {
153            panic!(
154                "failed to lock suite fixture state {}: {error}",
155                lock_path.display()
156            )
157        });
158        Self {
159            _file: file,
160            state_path,
161        }
162    }
163
164    /// Loads the last fully published state, or `None` when no fixture has been published.
165    ///
166    /// # Panics
167    ///
168    /// Panics when state cannot be read, decoded, or validated.
169    #[must_use]
170    pub fn load(&self) -> Option<SuiteFixtureState> {
171        if !self.state_path.exists() {
172            return None;
173        }
174
175        let mut raw = String::new();
176        File::open(&self.state_path)
177            .and_then(|mut file| file.read_to_string(&mut raw))
178            .unwrap_or_else(|error| {
179                panic!(
180                    "failed to read suite fixture state {}: {error}",
181                    self.state_path.display()
182                )
183            });
184        if raw.trim().is_empty() {
185            return None;
186        }
187
188        let state: SuiteFixtureState = serde_json::from_str(&raw).unwrap_or_else(|error| {
189            panic!(
190                "failed to parse suite fixture state {}: {error}",
191                self.state_path.display()
192            )
193        });
194        state.assert_valid();
195        Some(state)
196    }
197
198    /// Atomically publishes a completed fixture state while this guard is held.
199    ///
200    /// # Panics
201    ///
202    /// Panics when state is invalid or its temporary file cannot be written or published.
203    pub fn publish(&self, state: &SuiteFixtureState) {
204        state.assert_valid();
205        let payload = serde_json::to_vec(state)
206            .unwrap_or_else(|error| panic!("failed to serialize suite fixture state: {error}"));
207        let temporary_path = self.state_path.with_extension(format!(
208            "json.tmp-{}-{}",
209            std::process::id(),
210            TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed)
211        ));
212
213        let mut temporary = OpenOptions::new()
214            .create_new(true)
215            .write(true)
216            .open(&temporary_path)
217            .unwrap_or_else(|error| {
218                panic!(
219                    "failed to create suite fixture temporary state {}: {error}",
220                    temporary_path.display()
221                )
222            });
223        temporary
224            .write_all(&payload)
225            .and_then(|()| temporary.write_all(b"\n"))
226            .and_then(|()| temporary.sync_all())
227            .unwrap_or_else(|error| {
228                panic!(
229                    "failed to write suite fixture temporary state {}: {error}",
230                    temporary_path.display()
231                )
232            });
233        drop(temporary);
234
235        if let Err(error) = fs::rename(&temporary_path, &self.state_path) {
236            // Windows does not replace a destination through rename. Readers also hold this
237            // lock, so a brief missing state is safe: it deterministically triggers rebuild.
238            if self.state_path.exists() {
239                fs::remove_file(&self.state_path).unwrap_or_else(|remove_error| {
240                    panic!(
241                        "failed to replace suite fixture state {} after rename error {error}: {remove_error}",
242                        self.state_path.display()
243                    )
244                });
245                fs::rename(&temporary_path, &self.state_path).unwrap_or_else(|retry_error| {
246                    panic!(
247                        "failed to replace suite fixture state {} after rename error {error}: {retry_error}",
248                        self.state_path.display()
249                    )
250                });
251            } else {
252                panic!(
253                    "failed to publish suite fixture state {}: {error}",
254                    self.state_path.display()
255                );
256            }
257        }
258    }
259
260    /// Clears published state after product cleanup of a superseded fixture.
261    ///
262    /// # Panics
263    ///
264    /// Panics when an existing state file cannot be removed.
265    pub fn clear(&self) {
266        match fs::remove_file(&self.state_path) {
267            Ok(()) => {}
268            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
269            Err(error) => panic!(
270                "failed to clear suite fixture state {}: {error}",
271                self.state_path.display()
272            ),
273        }
274    }
275}
276
277fn assert_valid_fixture_name(name: &str) {
278    assert!(
279        !name.is_empty()
280            && name
281                .bytes()
282                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'),
283        "suite fixture name must be non-empty ASCII alphanumeric, '-' or '_': {name:?}"
284    );
285}
286
287#[cfg(test)]
288mod tests {
289    use super::{SuiteFixtureLock, SuiteFixtureState};
290    use crate::suite::TestContainerSuite;
291    use std::sync::mpsc;
292    use std::time::Duration;
293
294    #[test]
295    fn fixture_state_round_trips_and_matches_contract() {
296        let suite = TestContainerSuite::new("forge-fixture-state-test");
297        let lock = SuiteFixtureLock::acquire(&suite, "postgres-template");
298        lock.clear();
299
300        let state = SuiteFixtureState::new(
301            "postgres-template",
302            "aster-test-forge-fixture-state-test-postgres",
303            "migration-sha",
304            "fixture_database",
305            "asterdrive-0.4.0",
306        );
307        lock.publish(&state);
308
309        let loaded = lock.load().expect("fixture state should load");
310        assert_eq!(loaded, state);
311        assert!(loaded.matches(
312            "postgres-template",
313            "aster-test-forge-fixture-state-test-postgres",
314            "migration-sha",
315            "asterdrive-0.4.0",
316        ));
317        assert!(!loaded.matches(
318            "postgres-template",
319            "aster-test-forge-fixture-state-test-postgres",
320            "different-sha",
321            "asterdrive-0.4.0",
322        ));
323        lock.clear();
324    }
325
326    #[test]
327    fn fixture_lock_serializes_concurrent_publishers() {
328        let suite = TestContainerSuite::new("forge-fixture-lock-test");
329        let lock = SuiteFixtureLock::acquire(&suite, "schema-template");
330        lock.clear();
331
332        let (sender, receiver) = mpsc::channel();
333        let suite_for_thread = suite.clone();
334        let handle = std::thread::spawn(move || {
335            let other = SuiteFixtureLock::acquire(&suite_for_thread, "schema-template");
336            sender.send(()).expect("lock test receiver should exist");
337            other.clear();
338        });
339
340        assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err());
341        drop(lock);
342        receiver
343            .recv_timeout(Duration::from_secs(2))
344            .expect("second publisher should acquire after first lock releases");
345        handle
346            .join()
347            .expect("fixture lock test thread should finish");
348    }
349
350    #[test]
351    fn fixture_lock_rejects_unsafe_names() {
352        let suite = TestContainerSuite::new("forge-fixture-name-test");
353        for name in ["", "has space", "../escape", "unicode-测试"] {
354            assert!(
355                std::panic::catch_unwind(|| SuiteFixtureLock::acquire(&suite, name)).is_err(),
356                "fixture name {name:?} should be rejected"
357            );
358        }
359    }
360}