Skip to main content

cqlite_core/storage/sstable/
refresh.rs

1//! Explicit, concurrency-safe SSTable directory refresh (issue #1749).
2//!
3//! CQLite opens a data directory once and snapshots the discovered SSTable
4//! generations. A Cassandra flush/compaction (or a CQLite `--flush`) may add or
5//! remove generations underneath a long-lived handle at any time, but a warm
6//! session never re-scans on its own — that is the documented per-surface
7//! *freshness contract*: the library handle is a **snapshot at open**, and
8//! [`SSTableManager::refresh_tables`] is the ONLY way its reader set changes.
9//!
10//! `refresh_tables` re-runs the *same* directory discovery that
11//! [`StorageEngine::open`](crate::storage::StorageEngine::open) used (TOC /
12//! filename-component based — no content sniffing, no heuristics), diffs the
13//! discovered canonical `Data.db` paths against the held reader set, and applies
14//! the difference:
15//!
16//! - **added** generations are opened (standard [`SSTableReader::open`]:
17//!   bloom/Index/Statistics parsed, the issue #1626 corrupt-`Statistics.db`
18//!   hard-fail inherited) and become queryable;
19//! - **removed** generations are dropped (the underlying reader closes once the
20//!   last in-flight scan's `Arc` clone is dropped);
21//! - **unchanged** generations keep their existing `Arc<SSTableReader>` — their
22//!   parsed Index/Statistics/bloom state is *not* rebuilt (refresh of an
23//!   unchanged directory is ~free).
24//!
25//! Application is **atomic and fail-closed**: every added generation is opened
26//! *before* the write guard is taken, so if any open fails the whole refresh
27//! returns that typed error and mutates nothing — no partial view (Decision 3).
28//!
29//! Concurrency reuses the existing lock discipline (Decision 4): queries resolve
30//! their reader list once under the `RwLock` read guard and hold `Arc` clones, so
31//! a scan already in flight completes against the pre-refresh set and is never
32//! affected by a concurrent refresh. Two concurrent `refresh_tables` calls fully
33//! serialize on a dedicated refresh mutex held end-to-end (discovery through
34//! swap): the second cannot start until the first has applied its diff, so its
35//! discovery set already reflects the first's changes and it becomes a zero-delta
36//! no-op — a stale discovery set can never remove a generation a concurrent
37//! refresh just added. The refresh mutex is NEVER taken on a query path.
38
39use std::collections::{HashMap, HashSet};
40use std::path::{Path, PathBuf};
41use std::sync::Arc;
42
43use super::{
44    extract_keyspace_and_table_name, extract_table_name, is_apple_double_sidecar, reader,
45    SSTableId, SSTableManager, MAX_SSTABLE_SCAN_DEPTH,
46};
47use crate::Result;
48
49/// Outcome of a single [`SSTableManager::refresh_tables`] call.
50///
51/// Counts reflect what *this* refresh actually applied under the write guard:
52/// two concurrent refreshes serialize, and the second observes the first's
53/// changes already applied (so it reports a zero-delta no-op).
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct RefreshReport {
56    /// Number of distinct logical tables present after the refresh.
57    pub tables_scanned: usize,
58    /// SSTable generations newly opened and added by this refresh.
59    pub readers_added: usize,
60    /// SSTable generations dropped by this refresh (no longer on disk).
61    pub readers_removed: usize,
62}
63
64/// How a manager discovers its SSTable generations — recorded at construction so
65/// [`SSTableManager::refresh_tables`] re-runs *exactly* the same discovery.
66#[derive(Debug, Clone)]
67pub(crate) enum DiscoverySource {
68    /// Recursively scan `base_path` (mirrors `SSTableManager::new` /
69    /// `StorageEngine::open` — the library-handle path).
70    BasePath,
71    /// Scan a fixed set of pre-discovered table directories (mirrors
72    /// `SSTableManager::new_from_discovered_paths`).
73    TableDirs(Vec<PathBuf>),
74}
75
76/// Compute the `table_readers` key for a Data.db `path` using the **base-path**
77/// discovery keying — the single source of truth shared by `load_existing_sstables`
78/// and [`SSTableManager::refresh_tables`] so the two can never drift.
79///
80/// Priority (identical to the original inline logic, issue #680):
81///   1. `keyspace.table` from the filesystem path (when the table name is
82///      meaningful, i.e. not merely the scan base directory name);
83///   2. the unqualified table name from the path (same base-dir exclusion);
84///   3. the table name embedded in the SSTable header (last resort).
85pub(crate) fn base_path_table_key(
86    path: &Path,
87    base_dir_name: &str,
88    header_table_name: &str,
89) -> Option<String> {
90    if let Some((keyspace, table_name)) = extract_keyspace_and_table_name(path) {
91        if table_name.as_str() != base_dir_name {
92            return Some(format!("{}.{}", keyspace, table_name));
93        }
94    }
95    if let Some(name) = extract_table_name(path) {
96        if name.as_str() != base_dir_name {
97            return Some(name);
98        }
99    }
100    if header_table_name != "test_table" && !header_table_name.is_empty() {
101        return Some(header_table_name.to_string());
102    }
103    None
104}
105
106/// Compute the `table_readers` key for a Data.db `path` using the
107/// **table-directories** discovery keying — the single source of truth shared by
108/// `load_from_table_directories` and [`SSTableManager::refresh_tables`].
109pub(crate) fn table_dir_table_key(path: &Path) -> Option<String> {
110    if let Some((keyspace, table_name)) = extract_keyspace_and_table_name(path) {
111        Some(format!("{}.{}", keyspace, table_name))
112    } else {
113        extract_table_name(path)
114    }
115}
116
117/// Canonicalize `p` for stable diffing, falling back to the path as-given when
118/// the file no longer exists (a removed generation) or cannot be canonicalized.
119/// Never panics — uses `unwrap_or_else`, not `unwrap`.
120fn canon(p: &Path) -> PathBuf {
121    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
122}
123
124impl SSTableManager {
125    /// Open an `SSTableReader` at `path` and wire the schema/UDT registries onto
126    /// it (when the `state_machine` feature is enabled), returning a shared
127    /// handle. Extracted so the initial load paths and refresh open readers
128    /// identically. The `SSTableReader::open` error — including the issue #1626
129    /// corrupt-`Statistics.db` hard-fail — is propagated to the caller, which
130    /// decides whether to skip (initial best-effort load) or abort (fail-closed
131    /// refresh).
132    pub(crate) async fn open_reader_with_schema(
133        &self,
134        path: &Path,
135    ) -> Result<Arc<reader::SSTableReader>> {
136        #[cfg_attr(not(feature = "state_machine"), allow(unused_mut))]
137        let mut reader = reader::SSTableReader::open_with_cache(
138            path,
139            &self.config,
140            self.platform.clone(),
141            self.chunk_cache.clone(),
142        )
143        .await?;
144
145        #[cfg(feature = "state_machine")]
146        {
147            let schema_reg_guard = self.schema_registry.read().await;
148            if let Some(ref registry_rwlock) = *schema_reg_guard {
149                // Deliberate sync attach: we pre-resolve immediately below in this
150                // async context (the deprecation's recommended pattern; issue #1692).
151                #[allow(deprecated)]
152                reader.set_schema_registry(Arc::clone(registry_rwlock));
153
154                // Pre-resolve the registry schema into the reader's sync cache here,
155                // in this async context (issue #1692, AG3). The sync schema-fallback
156                // tier of `get_table_schema` then reads a plain field instead of
157                // `block_on`-ing the async registry on a tokio worker thread.
158                reader.resolve_registry_schema().await;
159
160                // Also set the UDT registry for UDT-aware collection parsing (Issue #238).
161                let schema_registry = registry_rwlock.read().await;
162                let udt_registry_lock = schema_registry.get_udt_registry();
163                let udt_registry = udt_registry_lock.read().await.clone();
164                reader.set_udt_registry(udt_registry);
165            }
166        }
167
168        Ok(Arc::new(reader))
169    }
170
171    /// List the current on-disk `Data.db` paths using the manager's recorded
172    /// [`DiscoverySource`] — the same discovery the manager was built with.
173    async fn discover_data_file_paths(&self) -> Result<Vec<PathBuf>> {
174        match &self.discovery_source {
175            DiscoverySource::BasePath => {
176                if !self.platform.fs().exists(&self.base_path).await? {
177                    return Ok(Vec::new());
178                }
179                SSTableManager::find_data_files(
180                    &self.platform,
181                    &self.base_path,
182                    MAX_SSTABLE_SCAN_DEPTH,
183                )
184                .await
185            }
186            DiscoverySource::TableDirs(dirs) => {
187                let mut out = Vec::new();
188                for dir in dirs {
189                    if !self.platform.fs().exists(dir).await? {
190                        continue;
191                    }
192                    let mut entries = match self.platform.fs().read_dir(dir).await {
193                        Ok(entries) => entries,
194                        Err(_) => continue,
195                    };
196                    while let Some(entry) = entries.next_entry().await? {
197                        let path = entry.path();
198                        if let Some(fname) = path.file_name().and_then(|n| n.to_str()) {
199                            if fname.ends_with("-Data.db") && !is_apple_double_sidecar(fname) {
200                                out.push(path);
201                            }
202                        }
203                    }
204                }
205                Ok(out)
206            }
207        }
208    }
209
210    /// Compute the `table_readers` key for a discovered `path`/`reader` under the
211    /// manager's discovery keying.
212    fn table_key_for(&self, path: &Path, reader: &reader::SSTableReader) -> Option<String> {
213        match &self.discovery_source {
214            DiscoverySource::BasePath => {
215                let base_dir_name = self
216                    .base_path
217                    .file_name()
218                    .and_then(|n| n.to_str())
219                    .unwrap_or("");
220                base_path_table_key(path, base_dir_name, &reader.header().table_name)
221            }
222            DiscoverySource::TableDirs(_) => table_dir_table_key(path),
223        }
224    }
225
226    /// Re-scan the data directory and atomically apply added/removed SSTable
227    /// generations to the held reader set. See the [module docs](self) for the
228    /// full contract (snapshot-at-open, explicit refresh, in-flight isolation,
229    /// fail-closed atomicity).
230    pub async fn refresh_tables(&self) -> Result<RefreshReport> {
231        // 0. Serialize the WHOLE refresh (discovery through swap) so two
232        //    concurrent refreshes cannot interleave: without this, a refresh with
233        //    a stale discovery set (step 1) could, at swap time (step 5), remove a
234        //    generation a concurrent refresh already added (TOCTOU, Decision 4).
235        //    Held only across the refresh; queries never take this lock.
236        let _refresh_guard = self.refresh_lock.lock().await;
237
238        // 1. Rediscover the current on-disk Data.db set (no readers opened yet).
239        let discovered_paths = self.discover_data_file_paths().await?;
240
241        // Precompute a raw-path -> canonical-path cache for EVERY path this
242        // refresh will diff. Filesystem canonicalization happens ONLY here (and
243        // in step 4 for freshly opened readers), never inside the write-guarded
244        // critical section (step 5), which must perform zero syscalls. A
245        // reader's `file_path` is immutable and `canon` is a pure function of
246        // path + filesystem, so a cache built now stays valid for the whole
247        // call.
248        let mut canon_cache: HashMap<PathBuf, PathBuf> =
249            HashMap::with_capacity(discovered_paths.len());
250        for p in &discovered_paths {
251            canon_cache.entry(p.clone()).or_insert_with(|| canon(p));
252        }
253
254        // 2. Snapshot the canonical paths currently held (short read guards),
255        //    extending the cache with every held reader's file_path so the
256        //    guarded section can resolve them without syscalls. Union both maps
257        //    so no held generation is missed even under a pre-existing
258        //    SSTableId (filename) collision across keyspaces.
259        let mut held_canon: HashSet<PathBuf> = HashSet::new();
260        {
261            let readers = self.readers.read().await;
262            let table_readers = self.table_readers.read().await;
263            for r in readers.values().chain(table_readers.values().flatten()) {
264                let c = canon_cache
265                    .entry(r.file_path.clone())
266                    .or_insert_with(|| canon(&r.file_path))
267                    .clone();
268                held_canon.insert(c);
269            }
270        }
271
272        // Cache-only canonicalization: reads the precomputed map, falling back
273        // to the raw path (NO syscall) for any path not seen during precompute
274        // — matching `canon`'s own fallback for an un-canonicalizable path.
275        let canon_of = |p: &Path| -> PathBuf {
276            canon_cache
277                .get(p)
278                .cloned()
279                .unwrap_or_else(|| p.to_path_buf())
280        };
281
282        let discovered_canon: HashSet<PathBuf> =
283            discovered_paths.iter().map(|p| canon_of(p)).collect();
284
285        // 3. Candidate additions = discovered paths not already held.
286        let added_paths: Vec<PathBuf> = discovered_paths
287            .iter()
288            .filter(|p| !held_canon.contains(&canon_of(p)))
289            .cloned()
290            .collect();
291
292        // 4. Open every added generation OUTSIDE the write guard. Fail-closed:
293        //    the first open error aborts the whole refresh, mutating nothing.
294        let mut opened: Vec<(
295            PathBuf,
296            SSTableId,
297            Option<String>,
298            Arc<reader::SSTableReader>,
299        )> = Vec::with_capacity(added_paths.len());
300        for path in &added_paths {
301            let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
302                continue;
303            };
304            let sstable_id = SSTableId::from_filename(filename);
305            let reader_arc = self.open_reader_with_schema(path).await?;
306            let key = self.table_key_for(path, &reader_arc);
307            opened.push((canon_of(path), sstable_id, key, reader_arc));
308        }
309
310        // 5. Apply the diff under the write guards (short critical section).
311        let mut readers = self.readers.write().await;
312        let mut table_readers = self.table_readers.write().await;
313
314        // 5a. Removal: retain only readers still present on disk. Every
315        //     canonical path below comes from the precomputed cache — the
316        //     guarded section performs zero filesystem syscalls.
317        let before_canon: HashSet<PathBuf> = readers
318            .values()
319            .map(|r| canon_of(&r.file_path))
320            .chain(
321                table_readers
322                    .values()
323                    .flatten()
324                    .map(|r| canon_of(&r.file_path)),
325            )
326            .collect();
327
328        readers.retain(|_id, r| discovered_canon.contains(&canon_of(&r.file_path)));
329        for list in table_readers.values_mut() {
330            list.retain(|r| discovered_canon.contains(&canon_of(&r.file_path)));
331        }
332        table_readers.retain(|_key, list| !list.is_empty());
333
334        let after_removal_canon: HashSet<PathBuf> = readers
335            .values()
336            .map(|r| canon_of(&r.file_path))
337            .chain(
338                table_readers
339                    .values()
340                    .flatten()
341                    .map(|r| canon_of(&r.file_path)),
342            )
343            .collect();
344        let readers_removed = before_canon.difference(&after_removal_canon).count();
345
346        // 5b. Addition: insert the pre-opened readers, skipping any a concurrent
347        //     refresh already added (preserving that refresh's warm Arc).
348        let mut readers_added = 0usize;
349        for (cpath, sstable_id, key, reader_arc) in opened {
350            if after_removal_canon.contains(&cpath) {
351                continue;
352            }
353            // Guard against inserting the same freshly-opened path twice if the
354            // discovery listed a duplicate (defensive; discovery does not).
355            if readers.values().any(|r| canon_of(&r.file_path) == cpath) {
356                continue;
357            }
358            readers.insert(sstable_id, Arc::clone(&reader_arc));
359            if let Some(key) = key {
360                table_readers.entry(key).or_default().push(reader_arc);
361            }
362            readers_added += 1;
363        }
364
365        let tables_scanned = table_readers.len();
366
367        Ok(RefreshReport {
368            tables_scanned,
369            readers_added,
370            readers_removed,
371        })
372    }
373}
374
375#[cfg(all(test, feature = "write-support", feature = "state_machine"))]
376mod tests {
377    use super::*;
378    use crate::schema::{Column, KeyColumn, TableSchema};
379    use crate::storage::write_engine::{
380        CellOperation, Mutation, PartitionKey, TableId, WriteEngine, WriteEngineConfig,
381    };
382    use crate::types::Value;
383    use crate::{Config, Platform};
384    use std::collections::HashMap;
385    use tempfile::TempDir;
386
387    fn users_schema() -> TableSchema {
388        TableSchema {
389            keyspace: "ks_ptr".to_string(),
390            table: "users".to_string(),
391            partition_keys: vec![KeyColumn {
392                name: "id".to_string(),
393                data_type: "int".to_string(),
394                position: 0,
395            }],
396            clustering_keys: vec![],
397            columns: vec![
398                Column {
399                    name: "id".to_string(),
400                    data_type: "int".to_string(),
401                    nullable: false,
402                    default: None,
403                    is_static: false,
404                },
405                Column {
406                    name: "value".to_string(),
407                    data_type: "text".to_string(),
408                    nullable: true,
409                    default: None,
410                    is_static: false,
411                },
412            ],
413            comments: HashMap::new(),
414            dropped_columns: HashMap::new(),
415        }
416    }
417
418    async fn flush_one_partition(data_dir: &Path, wal_dir: &Path, id: i32) {
419        let config = WriteEngineConfig::new(
420            data_dir.to_path_buf(),
421            wal_dir.to_path_buf(),
422            users_schema(),
423        );
424        let mut engine = WriteEngine::new(config).expect("write engine");
425        let table_id = TableId::new("ks_ptr", "users");
426        let pk = PartitionKey::single("id", Value::Integer(id));
427        let ops = vec![CellOperation::Write {
428            column: "value".to_string(),
429            value: Value::Text(format!("v{}", id)),
430        }];
431        engine
432            .write_async(Mutation::new(
433                table_id,
434                pk,
435                None,
436                ops,
437                1_000 + id as i64,
438                None,
439            ))
440            .await
441            .expect("write");
442        engine.flush().await.expect("flush");
443    }
444
445    /// Build a source dir holding two generations (`nb-1` = partition 1, `nb-2` =
446    /// partition 2), returning the `.../ks_ptr/users` table directory.
447    async fn build_two_generations(root: &Path) -> PathBuf {
448        let data_dir = root.join("data");
449        let wal_dir = root.join("wal");
450        let config = WriteEngineConfig::new(data_dir.clone(), wal_dir, users_schema());
451        let mut engine = WriteEngine::new(config).expect("write engine");
452        for id in [1_i32, 2_i32] {
453            let table_id = TableId::new("ks_ptr", "users");
454            let pk = PartitionKey::single("id", Value::Integer(id));
455            let ops = vec![CellOperation::Write {
456                column: "value".to_string(),
457                value: Value::Text(format!("v{}", id)),
458            }];
459            engine
460                .write_async(Mutation::new(
461                    table_id,
462                    pk,
463                    None,
464                    ops,
465                    1_000 + id as i64,
466                    None,
467                ))
468                .await
469                .expect("write");
470            engine.flush().await.expect("flush");
471        }
472        data_dir.join("ks_ptr").join("users")
473    }
474
475    fn copy_generation(src_table_dir: &Path, dst_table_dir: &Path, gen: u32) {
476        std::fs::create_dir_all(dst_table_dir).expect("mkdir dst");
477        let prefix = format!("nb-{}-big-", gen);
478        let mut copied = 0;
479        for entry in std::fs::read_dir(src_table_dir).expect("read src") {
480            let entry = entry.expect("entry");
481            let name = entry.file_name();
482            let name = name.to_str().expect("utf8");
483            if name.starts_with(&prefix) {
484                std::fs::copy(entry.path(), dst_table_dir.join(name)).expect("copy");
485                copied += 1;
486            }
487        }
488        assert!(copied > 0, "copied generation {} components", gen);
489    }
490
491    fn scan_partition_ids(
492        rows: &[(crate::RowKey, crate::ScanRow)],
493    ) -> std::collections::BTreeSet<i32> {
494        rows.iter()
495            .map(|(key, _)| {
496                let b = key.as_bytes();
497                assert_eq!(b.len(), 4, "int pk is 4 bytes");
498                i32::from_be_bytes([b[0], b[1], b[2], b[3]])
499            })
500            .collect()
501    }
502
503    /// Spec scenario "In-flight scan unaffected by concurrent refresh".
504    ///
505    /// A scan resolves its reader list once (under the read guard) and holds
506    /// `Arc` clones; a scan's output is therefore a pure function of the reader
507    /// set it captured. This test pins the isolation invariant deterministically
508    /// (no timing): it captures the exact `Arc` reader set an in-flight scan
509    /// would hold on the pre-refresh directory, adds a generation and refreshes,
510    /// and asserts (a) the captured set is byte-for-byte unchanged by the refresh
511    /// — so a scan bound to it still yields the pre-refresh content {1} — and
512    /// (b) a scan started AFTER the refresh sees the post-refresh content {1, 2}.
513    #[tokio::test]
514    async fn in_flight_scan_unaffected_by_concurrent_refresh() {
515        let src = TempDir::new().expect("tmp src");
516        let src_table_dir = build_two_generations(src.path()).await;
517
518        let live = TempDir::new().expect("tmp live");
519        let live_table_dir = live.path().join("ks_ptr").join("users");
520        copy_generation(&src_table_dir, &live_table_dir, 1);
521
522        let config = Config::default();
523        let platform = Arc::new(Platform::new(&config).await.expect("platform"));
524        let manager = SSTableManager::new(
525            live.path(),
526            &config,
527            platform,
528            #[cfg(feature = "state_machine")]
529            None,
530        )
531        .await
532        .expect("manager");
533
534        let schema = users_schema();
535        let table_id = crate::types::TableId::new("ks_ptr.users");
536
537        // Pre-refresh scan content is {1}; capture the exact Arc reader set an
538        // in-flight scan would hold (the read-guard snapshot the scan clones).
539        let pre_rows = manager
540            .scan(&table_id, None, None, None, Some(&schema))
541            .await
542            .expect("pre scan");
543        assert_eq!(
544            scan_partition_ids(&pre_rows),
545            std::collections::BTreeSet::from([1]),
546            "pre-refresh scan content is gen-1 only"
547        );
548        let held_snapshot: Vec<Arc<reader::SSTableReader>> = {
549            let table_readers = manager.table_readers.read().await;
550            table_readers.values().flatten().map(Arc::clone).collect()
551        };
552        assert_eq!(
553            held_snapshot.len(),
554            1,
555            "in-flight scan holds one gen-1 reader"
556        );
557
558        // Add gen-2 and refresh while that snapshot is "in flight".
559        copy_generation(&src_table_dir, &live_table_dir, 2);
560        let report = manager.refresh_tables().await.expect("refresh");
561        assert_eq!(report.readers_added, 1, "gen-2 added");
562
563        // (a) The captured in-flight reader set is unchanged by the refresh: the
564        //     same single gen-1 Arc (pointer identity), and NOT the gen-2 reader.
565        //     A scan bound to this unchanged set is therefore still exactly {1}.
566        let post_snapshot: Vec<Arc<reader::SSTableReader>> = {
567            let table_readers = manager.table_readers.read().await;
568            table_readers.values().flatten().map(Arc::clone).collect()
569        };
570        assert_eq!(post_snapshot.len(), 2, "manager now holds both generations");
571        assert_eq!(
572            held_snapshot.len(),
573            1,
574            "the in-flight snapshot is untouched by refresh"
575        );
576        assert!(
577            post_snapshot
578                .iter()
579                .any(|r| Arc::ptr_eq(r, &held_snapshot[0])),
580            "the pre-refresh gen-1 reader Arc survives the refresh unchanged"
581        );
582        let gen2_reader = post_snapshot
583            .iter()
584            .find(|r| !Arc::ptr_eq(r, &held_snapshot[0]))
585            .expect("gen-2 reader present post-refresh");
586        assert!(
587            !Arc::ptr_eq(gen2_reader, &held_snapshot[0]),
588            "the added gen-2 reader is NOT part of the in-flight snapshot (isolation)"
589        );
590
591        // (b) A scan started AFTER the refresh sees the post-refresh set {1, 2}.
592        let post_rows = manager
593            .scan(&table_id, None, None, None, Some(&schema))
594            .await
595            .expect("post scan");
596        assert_eq!(
597            scan_partition_ids(&post_rows),
598            std::collections::BTreeSet::from([1, 2]),
599            "post-refresh scan sees the new generation"
600        );
601    }
602
603    /// Spec scenario "Unchanged directory is a cheap no-op": a refresh over an
604    /// unchanged directory reports a zero delta AND keeps the exact same reader
605    /// `Arc` objects (pointer identity — warm state preserved).
606    #[tokio::test]
607    async fn refresh_noop_preserves_reader_arc_identity() {
608        let tmp = TempDir::new().expect("tmp");
609        let data_dir = tmp.path().join("data");
610        let wal_dir = tmp.path().join("wal");
611        flush_one_partition(&data_dir, &wal_dir, 1).await;
612
613        let config = Config::default();
614        let platform = Arc::new(Platform::new(&config).await.expect("platform"));
615        let manager = SSTableManager::new(
616            &data_dir,
617            &config,
618            platform,
619            #[cfg(feature = "state_machine")]
620            None,
621        )
622        .await
623        .expect("manager");
624
625        // Snapshot the reader Arc(s) for the table before refresh.
626        let before: Vec<Arc<reader::SSTableReader>> = {
627            let table_readers = manager.table_readers.read().await;
628            table_readers.values().flatten().map(Arc::clone).collect()
629        };
630        assert_eq!(before.len(), 1, "one generation expected pre-refresh");
631
632        let report = manager.refresh_tables().await.expect("refresh");
633        assert_eq!(report.readers_added, 0, "no-op: nothing added");
634        assert_eq!(report.readers_removed, 0, "no-op: nothing removed");
635
636        let after: Vec<Arc<reader::SSTableReader>> = {
637            let table_readers = manager.table_readers.read().await;
638            table_readers.values().flatten().map(Arc::clone).collect()
639        };
640        assert_eq!(after.len(), 1, "still one generation after no-op refresh");
641        assert!(
642            Arc::ptr_eq(&before[0], &after[0]),
643            "no-op refresh must keep the SAME reader Arc (warm state preserved)"
644        );
645    }
646
647    /// Two SEQUENTIAL refreshes with a generation added between them must both
648    /// apply correctly: the first is a no-op over the unchanged directory, the
649    /// second picks up the newly-added generation. Deterministic (no timing).
650    #[tokio::test]
651    async fn sequential_refreshes_each_apply_correctly() {
652        let src = TempDir::new().expect("tmp src");
653        let src_table_dir = build_two_generations(src.path()).await;
654
655        let live = TempDir::new().expect("tmp live");
656        let live_table_dir = live.path().join("ks_ptr").join("users");
657        copy_generation(&src_table_dir, &live_table_dir, 1);
658
659        let config = Config::default();
660        let platform = Arc::new(Platform::new(&config).await.expect("platform"));
661        let manager = SSTableManager::new(
662            live.path(),
663            &config,
664            platform,
665            #[cfg(feature = "state_machine")]
666            None,
667        )
668        .await
669        .expect("manager");
670
671        // First refresh over the unchanged directory: zero delta.
672        let first = manager.refresh_tables().await.expect("first refresh");
673        assert_eq!(first.readers_added, 0, "first refresh adds nothing");
674        assert_eq!(first.readers_removed, 0, "first refresh removes nothing");
675
676        // Add gen-2, then refresh again: the second refresh picks it up.
677        copy_generation(&src_table_dir, &live_table_dir, 2);
678        let second = manager.refresh_tables().await.expect("second refresh");
679        assert_eq!(second.readers_added, 1, "second refresh adds gen-2");
680        assert_eq!(second.readers_removed, 0, "second refresh removes nothing");
681
682        let held = {
683            let table_readers = manager.table_readers.read().await;
684            table_readers
685                .values()
686                .flatten()
687                .map(Arc::clone)
688                .collect::<Vec<_>>()
689        };
690        assert_eq!(
691            held.len(),
692            2,
693            "both generations held after the two refreshes"
694        );
695    }
696
697    /// Regression for the TOCTOU race (issue #1749, Decision 4): N `refresh()`
698    /// calls launched concurrently after a generation is added must serialize on
699    /// the refresh mutex. The final reader set must equal the on-disk truth
700    /// ({gen-1, gen-2}), EXACTLY ONE refresh may report the addition (the rest are
701    /// zero-delta no-ops), and NO reader may vanish (a stale-discovery refresh must
702    /// not remove what a concurrent refresh just added). Deterministic: correctness
703    /// is asserted on the final state and the aggregate deltas, with no timing.
704    #[tokio::test]
705    async fn concurrent_refreshes_serialize_no_reader_vanishes() {
706        let src = TempDir::new().expect("tmp src");
707        let src_table_dir = build_two_generations(src.path()).await;
708
709        let live = TempDir::new().expect("tmp live");
710        let live_table_dir = live.path().join("ks_ptr").join("users");
711        copy_generation(&src_table_dir, &live_table_dir, 1);
712
713        let config = Config::default();
714        let platform = Arc::new(Platform::new(&config).await.expect("platform"));
715        let manager = SSTableManager::new(
716            live.path(),
717            &config,
718            platform,
719            #[cfg(feature = "state_machine")]
720            None,
721        )
722        .await
723        .expect("manager");
724
725        // Add gen-2 on disk, then fire several refreshes concurrently.
726        copy_generation(&src_table_dir, &live_table_dir, 2);
727        let (r0, r1, r2, r3) = tokio::join!(
728            manager.refresh_tables(),
729            manager.refresh_tables(),
730            manager.refresh_tables(),
731            manager.refresh_tables(),
732        );
733        let reports = [
734            r0.expect("refresh 0"),
735            r1.expect("refresh 1"),
736            r2.expect("refresh 2"),
737            r3.expect("refresh 3"),
738        ];
739
740        // Exactly one refresh applied the +1 addition; the rest are no-ops. No
741        // refresh may report a removal (nothing left the directory).
742        let total_added: usize = reports.iter().map(|r| r.readers_added).sum();
743        let total_removed: usize = reports.iter().map(|r| r.readers_removed).sum();
744        assert_eq!(
745            total_added, 1,
746            "exactly one serialized refresh adds gen-2 (others are no-ops)"
747        );
748        assert_eq!(
749            total_removed, 0,
750            "no refresh may remove a reader a concurrent refresh just added"
751        );
752
753        // Final held set equals the on-disk truth: both generations, none vanished.
754        let held = {
755            let table_readers = manager.table_readers.read().await;
756            table_readers
757                .values()
758                .flatten()
759                .map(Arc::clone)
760                .collect::<Vec<_>>()
761        };
762        assert_eq!(
763            held.len(),
764            2,
765            "final reader set equals on-disk truth ({{gen-1, gen-2}})"
766        );
767        let scanned = manager
768            .scan(
769                &crate::types::TableId::new("ks_ptr.users"),
770                None,
771                None,
772                None,
773                Some(&users_schema()),
774            )
775            .await
776            .expect("post scan");
777        assert_eq!(
778            scan_partition_ids(&scanned),
779            std::collections::BTreeSet::from([1, 2]),
780            "both partitions queryable after concurrent refreshes"
781        );
782    }
783}